loki-mode 6.83.0 → 7.0.1
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/SKILL.md +62 -11
- package/VERSION +1 -1
- package/agents/managed_registry.py +246 -0
- package/agents/types.json +330 -0
- package/autonomy/completion-council.sh +226 -0
- package/autonomy/loki +346 -15
- package/autonomy/run.sh +357 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +235 -0
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/mcp/managed_tools.py +234 -0
- package/mcp/server.py +22 -0
- package/memory/managed_memory/__init__.py +9 -0
- package/memory/managed_memory/retrieve.py +237 -1
- package/package.json +4 -2
- package/providers/managed.py +789 -0
- package/skills/00-index.md +1 -0
- package/skills/memory.md +185 -0
package/autonomy/run.sh
CHANGED
|
@@ -649,7 +649,23 @@ export LOKI_SESSION_MODEL LOKI_LEGACY_TIER_SWITCHING
|
|
|
649
649
|
# Both off (default) => zero behavior change from v6.82.0.
|
|
650
650
|
LOKI_MANAGED_AGENTS="${LOKI_MANAGED_AGENTS:-false}"
|
|
651
651
|
LOKI_MANAGED_MEMORY="${LOKI_MANAGED_MEMORY:-false}"
|
|
652
|
-
|
|
652
|
+
# v7.0.0 Phase 2: remote->local hydrate on session boot (grandchild of MEMORY).
|
|
653
|
+
# Pulls semantic patterns + procedural skills once at init_loki_dir time.
|
|
654
|
+
LOKI_MANAGED_MEMORY_HYDRATE="${LOKI_MANAGED_MEMORY_HYDRATE:-false}"
|
|
655
|
+
# v7.0.0 Phase 3+4 foundation: umbrella flag for the multiagent-session path.
|
|
656
|
+
# Gates every providers/managed.py entry point (run_council,
|
|
657
|
+
# run_completion_council). Off by default because the Managed Agents
|
|
658
|
+
# multiagent surface is a research preview.
|
|
659
|
+
LOKI_EXPERIMENTAL_MANAGED_AGENTS="${LOKI_EXPERIMENTAL_MANAGED_AGENTS:-false}"
|
|
660
|
+
# v7.0.0 Phase 3 (T5): managed code-review council. Routes run_code_review
|
|
661
|
+
# through providers/managed.py::run_council when true. Requires parent +
|
|
662
|
+
# umbrella.
|
|
663
|
+
LOKI_EXPERIMENTAL_MANAGED_REVIEW="${LOKI_EXPERIMENTAL_MANAGED_REVIEW:-false}"
|
|
664
|
+
# v7.0.0 Phase 4 (T6): managed completion council. Routes council_should_stop
|
|
665
|
+
# through providers/managed.py::run_completion_council when true. Requires
|
|
666
|
+
# parent + umbrella.
|
|
667
|
+
LOKI_EXPERIMENTAL_MANAGED_COUNCIL="${LOKI_EXPERIMENTAL_MANAGED_COUNCIL:-false}"
|
|
668
|
+
export LOKI_MANAGED_AGENTS LOKI_MANAGED_MEMORY LOKI_MANAGED_MEMORY_HYDRATE LOKI_EXPERIMENTAL_MANAGED_AGENTS LOKI_EXPERIMENTAL_MANAGED_REVIEW LOKI_EXPERIMENTAL_MANAGED_COUNCIL
|
|
653
669
|
|
|
654
670
|
# Fail-fast: child on with parent off is a misconfiguration.
|
|
655
671
|
if [ "$LOKI_MANAGED_MEMORY" = "true" ] && [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
|
|
@@ -657,6 +673,47 @@ if [ "$LOKI_MANAGED_MEMORY" = "true" ] && [ "$LOKI_MANAGED_AGENTS" != "true" ];
|
|
|
657
673
|
exit 2
|
|
658
674
|
fi
|
|
659
675
|
|
|
676
|
+
# Phase 2 fail-fast: HYDRATE is a grandchild of MEMORY.
|
|
677
|
+
if [ "$LOKI_MANAGED_MEMORY_HYDRATE" = "true" ] && [ "$LOKI_MANAGED_MEMORY" != "true" ]; then
|
|
678
|
+
echo "ERROR: LOKI_MANAGED_MEMORY_HYDRATE=true requires LOKI_MANAGED_MEMORY=true" >&2
|
|
679
|
+
exit 2
|
|
680
|
+
fi
|
|
681
|
+
|
|
682
|
+
# Same fail-fast for the experimental multiagent session path.
|
|
683
|
+
if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" = "true" ] && [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
|
|
684
|
+
echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_AGENTS=true requires LOKI_MANAGED_AGENTS=true" >&2
|
|
685
|
+
exit 2
|
|
686
|
+
fi
|
|
687
|
+
|
|
688
|
+
# Phase 3 fail-fast: REVIEW requires parent AND umbrella.
|
|
689
|
+
if [ "$LOKI_EXPERIMENTAL_MANAGED_REVIEW" = "true" ]; then
|
|
690
|
+
if [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
|
|
691
|
+
echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_REVIEW=true requires LOKI_MANAGED_AGENTS=true" >&2
|
|
692
|
+
exit 2
|
|
693
|
+
fi
|
|
694
|
+
if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" != "true" ]; then
|
|
695
|
+
echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_REVIEW=true requires LOKI_EXPERIMENTAL_MANAGED_AGENTS=true" >&2
|
|
696
|
+
exit 2
|
|
697
|
+
fi
|
|
698
|
+
fi
|
|
699
|
+
|
|
700
|
+
# Phase 4 fail-fast: COUNCIL requires parent AND umbrella.
|
|
701
|
+
if [ "$LOKI_EXPERIMENTAL_MANAGED_COUNCIL" = "true" ]; then
|
|
702
|
+
if [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
|
|
703
|
+
echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_COUNCIL=true requires LOKI_MANAGED_AGENTS=true" >&2
|
|
704
|
+
exit 2
|
|
705
|
+
fi
|
|
706
|
+
if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" != "true" ]; then
|
|
707
|
+
echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_COUNCIL=true requires LOKI_EXPERIMENTAL_MANAGED_AGENTS=true" >&2
|
|
708
|
+
exit 2
|
|
709
|
+
fi
|
|
710
|
+
fi
|
|
711
|
+
|
|
712
|
+
# Research-preview warning banner.
|
|
713
|
+
if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" = "true" ]; then
|
|
714
|
+
echo "WARN: LOKI_EXPERIMENTAL_MANAGED_AGENTS uses Managed Agents research preview; expect beta churn." >&2
|
|
715
|
+
fi
|
|
716
|
+
|
|
660
717
|
# Parallel Workflows (Git Worktrees)
|
|
661
718
|
PARALLEL_MODE=${LOKI_PARALLEL_MODE:-false}
|
|
662
719
|
MAX_WORKTREES=${LOKI_MAX_WORKTREES:-5}
|
|
@@ -3021,6 +3078,30 @@ BUDGET_EOF
|
|
|
3021
3078
|
log_info "Budget limit set: \$$BUDGET_LIMIT"
|
|
3022
3079
|
fi
|
|
3023
3080
|
|
|
3081
|
+
# v7.0.0 Phase 2: remote->local hydrate. Runs ONCE at session boot (not
|
|
3082
|
+
# per iteration) to pull semantic patterns + procedural skills from the
|
|
3083
|
+
# managed store into .loki/memory/semantic/patterns.json and
|
|
3084
|
+
# .loki/memory/skills/*.json. Gated on parent + MEMORY + HYDRATE; all
|
|
3085
|
+
# three must be "true". 10s hard timeout so a slow remote never blocks
|
|
3086
|
+
# startup. Idempotent (sentinel: .loki/managed/hydrate.lock).
|
|
3087
|
+
if [ "$LOKI_MANAGED_AGENTS" = "true" ] \
|
|
3088
|
+
&& [ "$LOKI_MANAGED_MEMORY" = "true" ] \
|
|
3089
|
+
&& [ "$LOKI_MANAGED_MEMORY_HYDRATE" = "true" ]; then
|
|
3090
|
+
local _hydrate_target="${TARGET_DIR:-$(pwd)}"
|
|
3091
|
+
local _hydrate_out
|
|
3092
|
+
_hydrate_out=$(
|
|
3093
|
+
cd "$PROJECT_DIR" 2>/dev/null && \
|
|
3094
|
+
LOKI_TARGET_DIR="$_hydrate_target" \
|
|
3095
|
+
timeout 10 python3 -m memory.managed_memory.retrieve --hydrate 2>/dev/null || true
|
|
3096
|
+
)
|
|
3097
|
+
if [ -n "$_hydrate_out" ]; then
|
|
3098
|
+
log_info "Managed hydrate: $_hydrate_out"
|
|
3099
|
+
else
|
|
3100
|
+
LOKI_TARGET_DIR="$_hydrate_target" \
|
|
3101
|
+
python3 -c "from memory.managed_memory.events import emit_managed_event; emit_managed_event('managed_memory_hydrate_timeout', {'phase': 'init'})" 2>/dev/null || true
|
|
3102
|
+
fi
|
|
3103
|
+
fi
|
|
3104
|
+
|
|
3024
3105
|
log_info "Loki directory initialized: .loki/"
|
|
3025
3106
|
}
|
|
3026
3107
|
|
|
@@ -5869,6 +5950,236 @@ run_magic_debate_gate() {
|
|
|
5869
5950
|
# architecture-strategist always included, 2 more selected by keyword scoring
|
|
5870
5951
|
# ============================================================================
|
|
5871
5952
|
|
|
5953
|
+
# Write managed-council verdicts into the legacy per-reviewer .txt layout so
|
|
5954
|
+
# the dashboard quality panel (which only reads .loki/quality/reviews/$id/*.txt)
|
|
5955
|
+
# stays functional. Called from the managed branch of run_code_review().
|
|
5956
|
+
# Single-writer invariant: either this helper writes the files, or the legacy
|
|
5957
|
+
# CLI fan-out does -- never both for the same review_id.
|
|
5958
|
+
council_verdicts_to_txt_files() {
|
|
5959
|
+
local review_id="$1"
|
|
5960
|
+
local verdicts_json="$2"
|
|
5961
|
+
local loki_dir="${TARGET_DIR:-.}/.loki"
|
|
5962
|
+
local review_dir="$loki_dir/quality/reviews/$review_id"
|
|
5963
|
+
mkdir -p "$review_dir"
|
|
5964
|
+
|
|
5965
|
+
# Use python3 to fan the JSON verdict list out to individual .txt files
|
|
5966
|
+
# in the same VERDICT/FINDINGS format the legacy parser expects.
|
|
5967
|
+
local out_dir_env="$review_dir"
|
|
5968
|
+
export LOKI_COUNCIL_OUT_DIR="$out_dir_env"
|
|
5969
|
+
export LOKI_COUNCIL_VERDICTS_JSON="$verdicts_json"
|
|
5970
|
+
python3 << 'COUNCIL_WRITE'
|
|
5971
|
+
import json
|
|
5972
|
+
import os
|
|
5973
|
+
import re
|
|
5974
|
+
|
|
5975
|
+
out_dir = os.environ["LOKI_COUNCIL_OUT_DIR"]
|
|
5976
|
+
raw = os.environ.get("LOKI_COUNCIL_VERDICTS_JSON", "").strip()
|
|
5977
|
+
if not raw:
|
|
5978
|
+
raise SystemExit(0)
|
|
5979
|
+
|
|
5980
|
+
try:
|
|
5981
|
+
payload = json.loads(raw)
|
|
5982
|
+
except json.JSONDecodeError:
|
|
5983
|
+
raise SystemExit("council_verdicts_to_txt_files: invalid JSON")
|
|
5984
|
+
|
|
5985
|
+
if isinstance(payload, dict):
|
|
5986
|
+
verdicts = payload.get("verdicts") or []
|
|
5987
|
+
else:
|
|
5988
|
+
verdicts = payload or []
|
|
5989
|
+
|
|
5990
|
+
SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
|
5991
|
+
DOT_RUN = re.compile(r"\.{2,}")
|
|
5992
|
+
|
|
5993
|
+
def _pool_name(v):
|
|
5994
|
+
name = v.get("pool_name") or v.get("name") or v.get("agent_id") or "reviewer"
|
|
5995
|
+
cleaned = SAFE_NAME.sub("-", str(name))
|
|
5996
|
+
# Defend against path-traversal via ".." in pool names.
|
|
5997
|
+
cleaned = DOT_RUN.sub("-", cleaned).strip("-.")
|
|
5998
|
+
return cleaned[:80] or "reviewer"
|
|
5999
|
+
|
|
6000
|
+
def _verdict_token(v):
|
|
6001
|
+
token = str(v.get("verdict") or "").strip().upper()
|
|
6002
|
+
if token in ("APPROVE", "PASS"):
|
|
6003
|
+
return "PASS"
|
|
6004
|
+
if token in ("REQUEST_CHANGES", "REJECT", "FAIL"):
|
|
6005
|
+
return "FAIL"
|
|
6006
|
+
return "PASS" # ABSTAIN => PASS per legacy behavior
|
|
6007
|
+
|
|
6008
|
+
def _findings(v):
|
|
6009
|
+
rationale = (v.get("rationale") or "").strip()
|
|
6010
|
+
sev = v.get("severity")
|
|
6011
|
+
if not rationale:
|
|
6012
|
+
return "- None"
|
|
6013
|
+
lines = []
|
|
6014
|
+
for line in rationale.splitlines():
|
|
6015
|
+
line = line.strip()
|
|
6016
|
+
if not line:
|
|
6017
|
+
continue
|
|
6018
|
+
if line.lstrip().startswith("- ["):
|
|
6019
|
+
lines.append(line)
|
|
6020
|
+
else:
|
|
6021
|
+
tag = f"[{sev.capitalize()}]" if sev else "[Medium]"
|
|
6022
|
+
lines.append(f"- {tag} {line}")
|
|
6023
|
+
return "\n".join(lines) if lines else "- None"
|
|
6024
|
+
|
|
6025
|
+
for v in verdicts:
|
|
6026
|
+
if not isinstance(v, dict):
|
|
6027
|
+
continue
|
|
6028
|
+
name = _pool_name(v)
|
|
6029
|
+
path = os.path.join(out_dir, f"{name}.txt")
|
|
6030
|
+
body = f"VERDICT: {_verdict_token(v)}\nFINDINGS:\n{_findings(v)}\n"
|
|
6031
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
6032
|
+
f.write(body)
|
|
6033
|
+
COUNCIL_WRITE
|
|
6034
|
+
local rc=$?
|
|
6035
|
+
unset LOKI_COUNCIL_OUT_DIR LOKI_COUNCIL_VERDICTS_JSON
|
|
6036
|
+
return $rc
|
|
6037
|
+
}
|
|
6038
|
+
|
|
6039
|
+
# Execute the managed-agents multiagent council path. Writes legacy .txt
|
|
6040
|
+
# files via council_verdicts_to_txt_files() on success so the existing
|
|
6041
|
+
# aggregation loop below can read them exactly like the CLI path.
|
|
6042
|
+
# Returns 0 on success, 1 on ManagedUnavailable (caller should fall back).
|
|
6043
|
+
_run_managed_review_council() {
|
|
6044
|
+
local review_id="$1"
|
|
6045
|
+
local diff_file="$2"
|
|
6046
|
+
local files_file="$3"
|
|
6047
|
+
local review_dir="${TARGET_DIR:-.}/.loki/quality/reviews/$review_id"
|
|
6048
|
+
mkdir -p "$review_dir"
|
|
6049
|
+
|
|
6050
|
+
export LOKI_MANAGED_REVIEW_ID="$review_id"
|
|
6051
|
+
export LOKI_MANAGED_REVIEW_DIFF_FILE="$diff_file"
|
|
6052
|
+
export LOKI_MANAGED_REVIEW_FILES_FILE="$files_file"
|
|
6053
|
+
export LOKI_MANAGED_REVIEW_OUT_JSON="$review_dir/managed_result.json"
|
|
6054
|
+
local project_dir_env="${PROJECT_DIR:-.}"
|
|
6055
|
+
export LOKI_MANAGED_REVIEW_PROJECT_DIR="$project_dir_env"
|
|
6056
|
+
|
|
6057
|
+
local result_json
|
|
6058
|
+
result_json=$(python3 << 'MANAGED_REVIEW' 2>&1
|
|
6059
|
+
import json
|
|
6060
|
+
import os
|
|
6061
|
+
import sys
|
|
6062
|
+
|
|
6063
|
+
project_dir = os.environ.get("LOKI_MANAGED_REVIEW_PROJECT_DIR", ".")
|
|
6064
|
+
if project_dir and project_dir not in sys.path:
|
|
6065
|
+
sys.path.insert(0, project_dir)
|
|
6066
|
+
|
|
6067
|
+
try:
|
|
6068
|
+
from providers import managed as managed_mod
|
|
6069
|
+
except Exception as e:
|
|
6070
|
+
print(json.dumps({"status": "unavailable", "reason": f"import_failed: {e}"}))
|
|
6071
|
+
sys.exit(0)
|
|
6072
|
+
|
|
6073
|
+
# Test hook: allow tests to inject a fake run_council by setting
|
|
6074
|
+
# LOKI_MANAGED_REVIEW_FAKE_MODULE to a dotted path exposing run_council.
|
|
6075
|
+
fake_mod = os.environ.get("LOKI_MANAGED_REVIEW_FAKE_MODULE", "").strip()
|
|
6076
|
+
if fake_mod:
|
|
6077
|
+
try:
|
|
6078
|
+
import importlib
|
|
6079
|
+
fm = importlib.import_module(fake_mod)
|
|
6080
|
+
if hasattr(fm, "install"):
|
|
6081
|
+
fm.install(managed_mod)
|
|
6082
|
+
except Exception as e:
|
|
6083
|
+
print(json.dumps({"status": "unavailable", "reason": f"fake_install_failed: {e}"}))
|
|
6084
|
+
sys.exit(0)
|
|
6085
|
+
|
|
6086
|
+
if not managed_mod.is_enabled():
|
|
6087
|
+
print(json.dumps({"status": "unavailable", "reason": "is_enabled_false"}))
|
|
6088
|
+
sys.exit(0)
|
|
6089
|
+
|
|
6090
|
+
diff_path = os.environ.get("LOKI_MANAGED_REVIEW_DIFF_FILE", "")
|
|
6091
|
+
files_path = os.environ.get("LOKI_MANAGED_REVIEW_FILES_FILE", "")
|
|
6092
|
+
diff_text = ""
|
|
6093
|
+
files_text = ""
|
|
6094
|
+
if diff_path and os.path.exists(diff_path):
|
|
6095
|
+
with open(diff_path, "r", encoding="utf-8", errors="replace") as f:
|
|
6096
|
+
diff_text = f.read()
|
|
6097
|
+
if files_path and os.path.exists(files_path):
|
|
6098
|
+
with open(files_path, "r", encoding="utf-8", errors="replace") as f:
|
|
6099
|
+
files_text = f.read()
|
|
6100
|
+
|
|
6101
|
+
target_paths = [p.strip() for p in files_text.splitlines() if p.strip()]
|
|
6102
|
+
|
|
6103
|
+
pool = ["security-sentinel", "test-coverage-auditor", "performance-oracle"]
|
|
6104
|
+
context = {
|
|
6105
|
+
"diff": diff_text,
|
|
6106
|
+
"files": target_paths,
|
|
6107
|
+
"target_paths": target_paths,
|
|
6108
|
+
}
|
|
6109
|
+
|
|
6110
|
+
try:
|
|
6111
|
+
result = managed_mod.run_council(pool, context, timeout_s=300)
|
|
6112
|
+
except managed_mod.ManagedUnavailable as e:
|
|
6113
|
+
print(json.dumps({"status": "unavailable", "reason": str(e)}))
|
|
6114
|
+
sys.exit(0)
|
|
6115
|
+
except Exception as e:
|
|
6116
|
+
# Anything else is unexpected; bubble up as unavailable so the caller
|
|
6117
|
+
# falls back rather than aborting the iteration.
|
|
6118
|
+
print(json.dumps({"status": "unavailable", "reason": f"unexpected: {e}"}))
|
|
6119
|
+
sys.exit(0)
|
|
6120
|
+
|
|
6121
|
+
verdicts_out = []
|
|
6122
|
+
for v in (result.verdicts or []):
|
|
6123
|
+
verdicts_out.append({
|
|
6124
|
+
"agent_id": getattr(v, "agent_id", ""),
|
|
6125
|
+
"pool_name": getattr(v, "pool_name", ""),
|
|
6126
|
+
"verdict": getattr(v, "verdict", ""),
|
|
6127
|
+
"rationale": getattr(v, "rationale", ""),
|
|
6128
|
+
"severity": getattr(v, "severity", None),
|
|
6129
|
+
})
|
|
6130
|
+
out = {
|
|
6131
|
+
"status": "ok",
|
|
6132
|
+
"verdicts": verdicts_out,
|
|
6133
|
+
"session_id": getattr(result, "session_id", None),
|
|
6134
|
+
"elapsed_ms": getattr(result, "elapsed_ms", 0),
|
|
6135
|
+
"partial": getattr(result, "partial", False),
|
|
6136
|
+
}
|
|
6137
|
+
print(json.dumps(out))
|
|
6138
|
+
MANAGED_REVIEW
|
|
6139
|
+
)
|
|
6140
|
+
local py_rc=$?
|
|
6141
|
+
unset LOKI_MANAGED_REVIEW_ID LOKI_MANAGED_REVIEW_DIFF_FILE LOKI_MANAGED_REVIEW_FILES_FILE
|
|
6142
|
+
unset LOKI_MANAGED_REVIEW_OUT_JSON LOKI_MANAGED_REVIEW_PROJECT_DIR
|
|
6143
|
+
|
|
6144
|
+
if [ $py_rc -ne 0 ] || [ -z "$result_json" ]; then
|
|
6145
|
+
emit_event_json "managed_agents_fallback" \
|
|
6146
|
+
"op=run_code_review" \
|
|
6147
|
+
"reason=subprocess_failed" \
|
|
6148
|
+
"review_id=$review_id"
|
|
6149
|
+
return 1
|
|
6150
|
+
fi
|
|
6151
|
+
|
|
6152
|
+
local status
|
|
6153
|
+
status=$(printf '%s' "$result_json" | python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('status',''))" 2>/dev/null || echo "")
|
|
6154
|
+
|
|
6155
|
+
if [ "$status" != "ok" ]; then
|
|
6156
|
+
local reason
|
|
6157
|
+
reason=$(printf '%s' "$result_json" | python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('reason',''))" 2>/dev/null || echo "")
|
|
6158
|
+
emit_event_json "managed_agents_fallback" \
|
|
6159
|
+
"op=run_code_review" \
|
|
6160
|
+
"reason=managed_unavailable" \
|
|
6161
|
+
"detail=${reason//\"/}" \
|
|
6162
|
+
"review_id=$review_id"
|
|
6163
|
+
return 1
|
|
6164
|
+
fi
|
|
6165
|
+
|
|
6166
|
+
# Persist the raw managed result for observability and write legacy .txt
|
|
6167
|
+
# files for the dashboard panel / aggregation loop.
|
|
6168
|
+
printf '%s\n' "$result_json" > "$review_dir/managed_result.json"
|
|
6169
|
+
if ! council_verdicts_to_txt_files "$review_id" "$result_json"; then
|
|
6170
|
+
emit_event_json "managed_agents_fallback" \
|
|
6171
|
+
"op=run_code_review" \
|
|
6172
|
+
"reason=verdict_write_failed" \
|
|
6173
|
+
"review_id=$review_id"
|
|
6174
|
+
return 1
|
|
6175
|
+
fi
|
|
6176
|
+
|
|
6177
|
+
emit_event_json "managed_review_council_ok" \
|
|
6178
|
+
"review_id=$review_id" \
|
|
6179
|
+
"iteration=${ITERATION_COUNT:-0}"
|
|
6180
|
+
return 0
|
|
6181
|
+
}
|
|
6182
|
+
|
|
5872
6183
|
run_code_review() {
|
|
5873
6184
|
local loki_dir="${TARGET_DIR:-.}/.loki"
|
|
5874
6185
|
local review_dir="$loki_dir/quality/reviews"
|
|
@@ -5888,6 +6199,51 @@ run_code_review() {
|
|
|
5888
6199
|
changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "")
|
|
5889
6200
|
|
|
5890
6201
|
log_header "CODE REVIEW: $review_id"
|
|
6202
|
+
|
|
6203
|
+
# Phase 3 (v7.0.0): managed code-review council. When the flag is on,
|
|
6204
|
+
# route to providers/managed.py::run_council. On ManagedUnavailable,
|
|
6205
|
+
# emit a fallback event and drop through to the legacy CLI fan-out
|
|
6206
|
+
# below -- the existing v6.83.1 behavior is preserved.
|
|
6207
|
+
if [ "${LOKI_EXPERIMENTAL_MANAGED_REVIEW:-false}" = "true" ]; then
|
|
6208
|
+
local managed_diff_file="$review_dir/$review_id/diff.txt"
|
|
6209
|
+
local managed_files_file="$review_dir/$review_id/files.txt"
|
|
6210
|
+
printf '%s\n' "$diff_content" > "$managed_diff_file"
|
|
6211
|
+
printf '%s\n' "$changed_files" > "$managed_files_file"
|
|
6212
|
+
log_info "Managed review council: attempting multiagent session (Phase 3)"
|
|
6213
|
+
if _run_managed_review_council "$review_id" "$managed_diff_file" "$managed_files_file"; then
|
|
6214
|
+
log_info "Managed review council: verdicts written, skipping CLI fan-out"
|
|
6215
|
+
# Managed path wrote legacy .txt files; skip CLI fan-out but let
|
|
6216
|
+
# the aggregation step run by setting a minimal selection.json
|
|
6217
|
+
# the downstream loop can read.
|
|
6218
|
+
emit_event_json "code_review_complete" \
|
|
6219
|
+
"review_id=$review_id" \
|
|
6220
|
+
"source=managed" \
|
|
6221
|
+
"iteration=${ITERATION_COUNT:-0}"
|
|
6222
|
+
# Build a selection.json so any downstream consumer can find the
|
|
6223
|
+
# reviewer list. Mirrors the shape the CLI path writes below.
|
|
6224
|
+
python3 - "$review_dir/$review_id/selection.json" << 'MANAGED_SELECTION'
|
|
6225
|
+
import json
|
|
6226
|
+
import sys
|
|
6227
|
+
|
|
6228
|
+
path = sys.argv[1]
|
|
6229
|
+
selection = {
|
|
6230
|
+
"reviewers": [
|
|
6231
|
+
{"name": "security-sentinel", "focus": "managed", "checks": "managed council"},
|
|
6232
|
+
{"name": "test-coverage-auditor", "focus": "managed", "checks": "managed council"},
|
|
6233
|
+
{"name": "performance-oracle", "focus": "managed", "checks": "managed council"},
|
|
6234
|
+
],
|
|
6235
|
+
"scores": {},
|
|
6236
|
+
"pool_size": 3,
|
|
6237
|
+
"source": "managed",
|
|
6238
|
+
}
|
|
6239
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
6240
|
+
json.dump(selection, f)
|
|
6241
|
+
MANAGED_SELECTION
|
|
6242
|
+
return 0
|
|
6243
|
+
fi
|
|
6244
|
+
log_warn "Managed review council unavailable; falling back to CLI fan-out"
|
|
6245
|
+
fi
|
|
6246
|
+
|
|
5891
6247
|
log_info "Selecting 3 specialist reviewers from pool..."
|
|
5892
6248
|
|
|
5893
6249
|
# Write diff/files to temp files for python to read (avoid env var size limits)
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -5559,6 +5559,241 @@ def start_migration_phase(migration_id: str, request_body: dict):
|
|
|
5559
5559
|
raise HTTPException(status_code=500, detail="Failed to start phase")
|
|
5560
5560
|
|
|
5561
5561
|
|
|
5562
|
+
# ---------------------------------------------------------------------------
|
|
5563
|
+
# Managed Agents Memory bridge (Phase 5, read-only)
|
|
5564
|
+
#
|
|
5565
|
+
# These endpoints expose the contents of .loki/managed/events.ndjson plus a
|
|
5566
|
+
# thin proxy to beta.memory_stores.memory_versions.list(). All endpoints are
|
|
5567
|
+
# safe to call when the managed-agents flags are off: they return empty
|
|
5568
|
+
# lists / {enabled: false} rather than 500s. No endpoint writes to the
|
|
5569
|
+
# managed store -- the only writer in the codebase remains
|
|
5570
|
+
# memory/managed_memory/shadow_write.py.
|
|
5571
|
+
# ---------------------------------------------------------------------------
|
|
5572
|
+
|
|
5573
|
+
_MANAGED_EVENTS_TAIL_MAX = 10000 # Safety ceiling on tail reads.
|
|
5574
|
+
|
|
5575
|
+
|
|
5576
|
+
def _managed_events_path() -> _Path:
|
|
5577
|
+
"""Return the absolute path to .loki/managed/events.ndjson."""
|
|
5578
|
+
return _get_loki_dir() / "managed" / "events.ndjson"
|
|
5579
|
+
|
|
5580
|
+
|
|
5581
|
+
def _managed_flags_snapshot() -> dict[str, Any]:
|
|
5582
|
+
"""Read managed-agents flags without importing the SDK path."""
|
|
5583
|
+
parent = os.environ.get("LOKI_MANAGED_AGENTS", "").strip().lower() == "true"
|
|
5584
|
+
child = os.environ.get("LOKI_MANAGED_MEMORY", "").strip().lower() == "true"
|
|
5585
|
+
try:
|
|
5586
|
+
from memory.managed_memory._beta import BETA_HEADER as _beta_header
|
|
5587
|
+
except Exception:
|
|
5588
|
+
_beta_header = "managed-agents-2026-04-01"
|
|
5589
|
+
return {
|
|
5590
|
+
"enabled": parent and child,
|
|
5591
|
+
"parent_flag": parent,
|
|
5592
|
+
"child_flags": {"LOKI_MANAGED_MEMORY": child},
|
|
5593
|
+
"beta_header": _beta_header,
|
|
5594
|
+
}
|
|
5595
|
+
|
|
5596
|
+
|
|
5597
|
+
def _tail_ndjson(
|
|
5598
|
+
path: _Path,
|
|
5599
|
+
limit: int,
|
|
5600
|
+
since_iso: Optional[str],
|
|
5601
|
+
event_type: Optional[str],
|
|
5602
|
+
) -> list[dict[str, Any]]:
|
|
5603
|
+
"""
|
|
5604
|
+
Return the last *limit* records from an ndjson file, optionally filtered
|
|
5605
|
+
by ts >= since_iso and/or type == event_type. The file is streamed line
|
|
5606
|
+
by line; malformed lines are skipped rather than raising.
|
|
5607
|
+
"""
|
|
5608
|
+
if not path.exists():
|
|
5609
|
+
return []
|
|
5610
|
+
try:
|
|
5611
|
+
# Read lines (file is small: rotation at 10MB per the writer).
|
|
5612
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
5613
|
+
lines = f.readlines()
|
|
5614
|
+
except OSError:
|
|
5615
|
+
return []
|
|
5616
|
+
|
|
5617
|
+
results: list[dict[str, Any]] = []
|
|
5618
|
+
# Scan from newest to oldest so we can early-exit once we have enough.
|
|
5619
|
+
for raw in reversed(lines):
|
|
5620
|
+
line = raw.strip()
|
|
5621
|
+
if not line:
|
|
5622
|
+
continue
|
|
5623
|
+
try:
|
|
5624
|
+
record = json.loads(line)
|
|
5625
|
+
except (json.JSONDecodeError, ValueError):
|
|
5626
|
+
continue
|
|
5627
|
+
if not isinstance(record, dict):
|
|
5628
|
+
continue
|
|
5629
|
+
if event_type and record.get("type") != event_type:
|
|
5630
|
+
continue
|
|
5631
|
+
if since_iso:
|
|
5632
|
+
ts = record.get("ts", "")
|
|
5633
|
+
if isinstance(ts, str) and ts < since_iso:
|
|
5634
|
+
# ISO-8601 strings sort lexicographically with Z suffix; once
|
|
5635
|
+
# we pass the floor we can stop scanning.
|
|
5636
|
+
break
|
|
5637
|
+
results.append(record)
|
|
5638
|
+
if len(results) >= limit:
|
|
5639
|
+
break
|
|
5640
|
+
# Return in chronological order (oldest first) for UI convenience.
|
|
5641
|
+
results.reverse()
|
|
5642
|
+
return results
|
|
5643
|
+
|
|
5644
|
+
|
|
5645
|
+
def _last_fallback_ts(events: list[dict[str, Any]]) -> Optional[str]:
|
|
5646
|
+
"""Return the ts of the most recent managed_agents_fallback event, if any."""
|
|
5647
|
+
for rec in reversed(events):
|
|
5648
|
+
if rec.get("type") == "managed_agents_fallback":
|
|
5649
|
+
ts = rec.get("ts")
|
|
5650
|
+
return ts if isinstance(ts, str) else None
|
|
5651
|
+
return None
|
|
5652
|
+
|
|
5653
|
+
|
|
5654
|
+
@app.get("/api/managed/events")
|
|
5655
|
+
async def get_managed_events(
|
|
5656
|
+
limit: int = Query(default=100, ge=1, le=_MANAGED_EVENTS_TAIL_MAX),
|
|
5657
|
+
since: Optional[str] = Query(default=None),
|
|
5658
|
+
type: Optional[str] = Query(default=None, alias="type"),
|
|
5659
|
+
):
|
|
5660
|
+
"""
|
|
5661
|
+
Return the tail of .loki/managed/events.ndjson.
|
|
5662
|
+
|
|
5663
|
+
Works regardless of flag state. When the flags are off or the file does
|
|
5664
|
+
not exist yet, returns an empty list. Never raises on I/O error.
|
|
5665
|
+
"""
|
|
5666
|
+
try:
|
|
5667
|
+
path = _managed_events_path()
|
|
5668
|
+
records = _tail_ndjson(path, limit=limit, since_iso=since, event_type=type)
|
|
5669
|
+
return {
|
|
5670
|
+
"events": records,
|
|
5671
|
+
"count": len(records),
|
|
5672
|
+
"source": str(path),
|
|
5673
|
+
}
|
|
5674
|
+
except Exception as exc: # defensive: never 500 on read-only tail.
|
|
5675
|
+
logger.warning("managed events tail failed: %s", exc)
|
|
5676
|
+
return {"events": [], "count": 0, "error": str(exc)}
|
|
5677
|
+
|
|
5678
|
+
|
|
5679
|
+
@app.get("/api/managed/status")
|
|
5680
|
+
async def get_managed_status():
|
|
5681
|
+
"""
|
|
5682
|
+
Return the managed-agents flag snapshot plus last_fallback_ts.
|
|
5683
|
+
|
|
5684
|
+
When flags are off, returns {enabled: false, ...} rather than 503. This
|
|
5685
|
+
endpoint is meant to be polled by the UI to decide whether to surface
|
|
5686
|
+
the managed-memory panel at all.
|
|
5687
|
+
"""
|
|
5688
|
+
snapshot = _managed_flags_snapshot()
|
|
5689
|
+
# last_fallback_ts is best-effort from the local events file.
|
|
5690
|
+
try:
|
|
5691
|
+
events = _tail_ndjson(
|
|
5692
|
+
_managed_events_path(),
|
|
5693
|
+
limit=500,
|
|
5694
|
+
since_iso=None,
|
|
5695
|
+
event_type="managed_agents_fallback",
|
|
5696
|
+
)
|
|
5697
|
+
snapshot["last_fallback_ts"] = _last_fallback_ts(events)
|
|
5698
|
+
except Exception:
|
|
5699
|
+
snapshot["last_fallback_ts"] = None
|
|
5700
|
+
return snapshot
|
|
5701
|
+
|
|
5702
|
+
|
|
5703
|
+
@app.get("/api/managed/memory_versions/{memory_id}")
|
|
5704
|
+
async def list_managed_memory_versions(memory_id: str):
|
|
5705
|
+
"""
|
|
5706
|
+
Proxy to beta.memory_stores.memory_versions.list(memory_id=...).
|
|
5707
|
+
|
|
5708
|
+
Returns 503 with a helpful JSON body when flags are off or the SDK does
|
|
5709
|
+
not expose the expected attribute path. On any SDK / transport error the
|
|
5710
|
+
endpoint returns 502 with the error detail -- the managed store owns the
|
|
5711
|
+
source of truth, so we do NOT silently return an empty list here.
|
|
5712
|
+
"""
|
|
5713
|
+
# Validate memory_id early so we don't leak path-traversal attempts into
|
|
5714
|
+
# the SDK payload. The managed API uses opaque identifiers; alphanumerics,
|
|
5715
|
+
# hyphens, underscores only.
|
|
5716
|
+
if (
|
|
5717
|
+
not memory_id
|
|
5718
|
+
or len(memory_id) > 256
|
|
5719
|
+
or ".." in memory_id
|
|
5720
|
+
or not re.match(r"^[a-zA-Z0-9_\-]+$", memory_id)
|
|
5721
|
+
):
|
|
5722
|
+
raise HTTPException(status_code=400, detail="Invalid memory_id")
|
|
5723
|
+
|
|
5724
|
+
snapshot = _managed_flags_snapshot()
|
|
5725
|
+
if not snapshot["enabled"]:
|
|
5726
|
+
raise HTTPException(
|
|
5727
|
+
status_code=503,
|
|
5728
|
+
detail=(
|
|
5729
|
+
"managed memory disabled: set LOKI_MANAGED_AGENTS=true and "
|
|
5730
|
+
"LOKI_MANAGED_MEMORY=true to enable"
|
|
5731
|
+
),
|
|
5732
|
+
)
|
|
5733
|
+
|
|
5734
|
+
try:
|
|
5735
|
+
from memory.managed_memory import ManagedDisabled
|
|
5736
|
+
from memory.managed_memory.client import get_client
|
|
5737
|
+
except Exception as exc:
|
|
5738
|
+
raise HTTPException(status_code=503, detail=f"managed client unavailable: {exc}")
|
|
5739
|
+
|
|
5740
|
+
try:
|
|
5741
|
+
client = get_client()
|
|
5742
|
+
except ManagedDisabled as exc:
|
|
5743
|
+
raise HTTPException(status_code=503, detail=str(exc))
|
|
5744
|
+
|
|
5745
|
+
# Resolve beta.memory_stores.memory_versions.list(...) defensively. Some
|
|
5746
|
+
# SDK versions may not expose this path yet; treat missing attributes as
|
|
5747
|
+
# 503 (flag state prevents us from guaranteeing anything else).
|
|
5748
|
+
try:
|
|
5749
|
+
beta = getattr(client._client, "beta", None) # type: ignore[attr-defined]
|
|
5750
|
+
memory_stores = getattr(beta, "memory_stores", None) if beta is not None else None
|
|
5751
|
+
memory_versions = (
|
|
5752
|
+
getattr(memory_stores, "memory_versions", None)
|
|
5753
|
+
if memory_stores is not None
|
|
5754
|
+
else None
|
|
5755
|
+
)
|
|
5756
|
+
list_fn = getattr(memory_versions, "list", None) if memory_versions is not None else None
|
|
5757
|
+
if list_fn is None:
|
|
5758
|
+
raise HTTPException(
|
|
5759
|
+
status_code=503,
|
|
5760
|
+
detail="memory_versions.list not available in installed SDK",
|
|
5761
|
+
)
|
|
5762
|
+
except HTTPException:
|
|
5763
|
+
raise
|
|
5764
|
+
except Exception as exc:
|
|
5765
|
+
raise HTTPException(status_code=503, detail=f"SDK introspection failed: {exc}")
|
|
5766
|
+
|
|
5767
|
+
try:
|
|
5768
|
+
result = list_fn(memory_id=memory_id)
|
|
5769
|
+
except Exception as exc:
|
|
5770
|
+
# Distinguish "not found" from transport errors when we can.
|
|
5771
|
+
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
|
5772
|
+
if status == 404:
|
|
5773
|
+
raise HTTPException(status_code=404, detail=f"memory_id not found: {memory_id}")
|
|
5774
|
+
logger.warning("memory_versions.list failed: %s", exc)
|
|
5775
|
+
raise HTTPException(status_code=502, detail=f"managed API error: {exc}")
|
|
5776
|
+
|
|
5777
|
+
# Normalize to a list of dicts.
|
|
5778
|
+
data = getattr(result, "data", result)
|
|
5779
|
+
if data is None:
|
|
5780
|
+
data = []
|
|
5781
|
+
items: list[dict[str, Any]] = []
|
|
5782
|
+
for entry in data:
|
|
5783
|
+
if isinstance(entry, dict):
|
|
5784
|
+
items.append(entry)
|
|
5785
|
+
continue
|
|
5786
|
+
to_dict = getattr(entry, "model_dump", None) or getattr(entry, "dict", None)
|
|
5787
|
+
if callable(to_dict):
|
|
5788
|
+
try:
|
|
5789
|
+
items.append(to_dict())
|
|
5790
|
+
continue
|
|
5791
|
+
except Exception:
|
|
5792
|
+
pass
|
|
5793
|
+
items.append({"raw": str(entry)})
|
|
5794
|
+
return {"memory_id": memory_id, "versions": items, "count": len(items)}
|
|
5795
|
+
|
|
5796
|
+
|
|
5562
5797
|
# ---------------------------------------------------------------------------
|
|
5563
5798
|
# SPA catch-all: serve index.html for any path not matched by API routes
|
|
5564
5799
|
# or static asset mounts. This lets the dashboard UI handle client-side routing.
|
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED