loki-mode 6.82.0 → 6.83.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 +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +58 -0
- package/autonomy/run.sh +85 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/mcp/requirements.txt +1 -0
- package/memory/managed_memory/__init__.py +113 -0
- package/memory/managed_memory/_beta.py +11 -0
- package/memory/managed_memory/client.py +210 -0
- package/memory/managed_memory/events.py +79 -0
- package/memory/managed_memory/fakes.py +120 -0
- package/memory/managed_memory/retrieve.py +347 -0
- package/memory/managed_memory/shadow_write.py +350 -0
- package/package.json +2 -2
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v6.
|
|
6
|
+
# Loki Mode v6.83.1
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -269,4 +269,4 @@ The following features are documented in skill modules but not yet fully automat
|
|
|
269
269
|
| Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
|
|
270
270
|
| Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
271
271
|
|
|
272
|
-
**v6.
|
|
272
|
+
**v6.83.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.
|
|
1
|
+
6.83.1
|
|
@@ -72,6 +72,38 @@ COUNCIL_DONE_SIGNALS=0
|
|
|
72
72
|
COUNCIL_TOTAL_DONE_SIGNALS=0
|
|
73
73
|
COUNCIL_LAST_DIFF_HASH=""
|
|
74
74
|
|
|
75
|
+
#===============================================================================
|
|
76
|
+
# v6.83.0 Phase 1: Managed Agents memory augmentation (opt-in).
|
|
77
|
+
#
|
|
78
|
+
# When LOKI_MANAGED_AGENTS=true AND LOKI_MANAGED_MEMORY=true, this function
|
|
79
|
+
# pulls up to 3 related prior verdicts from the Claude Managed Agents store
|
|
80
|
+
# and writes them to a file the council prompt-assembly step appends as
|
|
81
|
+
# "RELATED PRIOR VERDICTS". 5s hard timeout so a slow/unreachable API can
|
|
82
|
+
# never block the council. Silent no-op when the flags are off.
|
|
83
|
+
#===============================================================================
|
|
84
|
+
council_augment_from_managed_memory() {
|
|
85
|
+
if [ "${LOKI_MANAGED_AGENTS:-false}" != "true" ] || \
|
|
86
|
+
[ "${LOKI_MANAGED_MEMORY:-false}" != "true" ]; then
|
|
87
|
+
return 0
|
|
88
|
+
fi
|
|
89
|
+
local target_dir="${TARGET_DIR:-.}"
|
|
90
|
+
local project_dir="${PROJECT_DIR:-$(pwd)}"
|
|
91
|
+
local out_file="$target_dir/.loki/managed/council-augment.txt"
|
|
92
|
+
mkdir -p "$target_dir/.loki/managed" 2>/dev/null || true
|
|
93
|
+
(
|
|
94
|
+
cd "$project_dir" 2>/dev/null && \
|
|
95
|
+
LOKI_TARGET_DIR="$target_dir" \
|
|
96
|
+
timeout 5 python3 -m memory.managed_memory.retrieve \
|
|
97
|
+
--query "completion-council verdict context" --top-k 3 \
|
|
98
|
+
> "$out_file" 2>/dev/null || true
|
|
99
|
+
) || true
|
|
100
|
+
if [ -s "$out_file" ]; then
|
|
101
|
+
echo "RELATED PRIOR VERDICTS:"
|
|
102
|
+
cat "$out_file"
|
|
103
|
+
fi
|
|
104
|
+
return 0
|
|
105
|
+
}
|
|
106
|
+
|
|
75
107
|
#===============================================================================
|
|
76
108
|
# Initialization
|
|
77
109
|
#===============================================================================
|
|
@@ -1366,6 +1398,11 @@ council_should_stop() {
|
|
|
1366
1398
|
return 1
|
|
1367
1399
|
fi
|
|
1368
1400
|
|
|
1401
|
+
# v6.83.0 Phase 1: silent no-op unless both managed flags are on.
|
|
1402
|
+
# Writes related prior verdicts into $TARGET_DIR/.loki/managed/council-augment.txt
|
|
1403
|
+
# which the council prompt assembly step can read and append to its prompt.
|
|
1404
|
+
council_augment_from_managed_memory >/dev/null 2>&1 || true
|
|
1405
|
+
|
|
1369
1406
|
# Check circuit breaker first (stagnation detection)
|
|
1370
1407
|
local circuit_triggered=false
|
|
1371
1408
|
if council_circuit_breaker_triggered; then
|
|
@@ -1396,6 +1433,27 @@ council_should_stop() {
|
|
|
1396
1433
|
# Store final council report
|
|
1397
1434
|
council_write_report
|
|
1398
1435
|
|
|
1436
|
+
# v6.83.0 Phase 1: shadow-write the final council verdict to the
|
|
1437
|
+
# managed memory store. Backgrounded + silent; flags gate the work
|
|
1438
|
+
# inside the Python module so no-op when off.
|
|
1439
|
+
if [ "${LOKI_MANAGED_AGENTS:-false}" = "true" ] && \
|
|
1440
|
+
[ "${LOKI_MANAGED_MEMORY:-false}" = "true" ]; then
|
|
1441
|
+
local _verdict_file="$loki_dir/council/verdicts/iteration-$ITERATION_COUNT.json"
|
|
1442
|
+
if [ ! -f "$_verdict_file" ]; then
|
|
1443
|
+
# Fall back to the round vote file as the verdict payload.
|
|
1444
|
+
_verdict_file="$COUNCIL_STATE_DIR/votes/round-${ITERATION_COUNT}.json"
|
|
1445
|
+
fi
|
|
1446
|
+
if [ -f "$_verdict_file" ]; then
|
|
1447
|
+
(
|
|
1448
|
+
cd "${PROJECT_DIR:-$(pwd)}" 2>/dev/null && \
|
|
1449
|
+
LOKI_TARGET_DIR="$loki_dir/.." \
|
|
1450
|
+
timeout 15 python3 -m memory.managed_memory.shadow_write \
|
|
1451
|
+
--verdict "$_verdict_file" >/dev/null 2>&1 || true
|
|
1452
|
+
) &
|
|
1453
|
+
disown 2>/dev/null || true
|
|
1454
|
+
fi
|
|
1455
|
+
fi
|
|
1456
|
+
|
|
1399
1457
|
return 0 # STOP
|
|
1400
1458
|
fi
|
|
1401
1459
|
|
package/autonomy/run.sh
CHANGED
|
@@ -644,6 +644,19 @@ LOKI_SESSION_MODEL="${LOKI_SESSION_MODEL:-sonnet}"
|
|
|
644
644
|
LOKI_LEGACY_TIER_SWITCHING="${LOKI_LEGACY_TIER_SWITCHING:-false}"
|
|
645
645
|
export LOKI_SESSION_MODEL LOKI_LEGACY_TIER_SWITCHING
|
|
646
646
|
|
|
647
|
+
# Managed Agents (v6.83.0 Phase 1): opt-in integration with Claude Managed Agents
|
|
648
|
+
# Memory store backend. Parent flag gates everything; child flags gate features.
|
|
649
|
+
# Both off (default) => zero behavior change from v6.82.0.
|
|
650
|
+
LOKI_MANAGED_AGENTS="${LOKI_MANAGED_AGENTS:-false}"
|
|
651
|
+
LOKI_MANAGED_MEMORY="${LOKI_MANAGED_MEMORY:-false}"
|
|
652
|
+
export LOKI_MANAGED_AGENTS LOKI_MANAGED_MEMORY
|
|
653
|
+
|
|
654
|
+
# Fail-fast: child on with parent off is a misconfiguration.
|
|
655
|
+
if [ "$LOKI_MANAGED_MEMORY" = "true" ] && [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
|
|
656
|
+
echo "ERROR: LOKI_MANAGED_MEMORY=true requires LOKI_MANAGED_AGENTS=true" >&2
|
|
657
|
+
exit 2
|
|
658
|
+
fi
|
|
659
|
+
|
|
647
660
|
# Parallel Workflows (Git Worktrees)
|
|
648
661
|
PARALLEL_MODE=${LOKI_PARALLEL_MODE:-false}
|
|
649
662
|
MAX_WORKTREES=${LOKI_MAX_WORKTREES:-5}
|
|
@@ -7977,6 +7990,32 @@ try:
|
|
|
7977
7990
|
except Exception as e:
|
|
7978
7991
|
pass # Silently fail if memory not available
|
|
7979
7992
|
PYEOF
|
|
7993
|
+
|
|
7994
|
+
# v6.83.0 Phase 1: RARV-C REASON augment. When both managed flags are on,
|
|
7995
|
+
# pull related prior verdicts from the Claude Managed Agents store and
|
|
7996
|
+
# append them AFTER local results. 5s hard timeout so a slow remote never
|
|
7997
|
+
# blocks the loop. On timeout or error, emit a fallback event and continue.
|
|
7998
|
+
if [ "$LOKI_MANAGED_AGENTS" = "true" ] && [ "$LOKI_MANAGED_MEMORY" = "true" ]; then
|
|
7999
|
+
local managed_start_ms
|
|
8000
|
+
managed_start_ms=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo "0")
|
|
8001
|
+
local managed_out
|
|
8002
|
+
managed_out=$(
|
|
8003
|
+
cd "$PROJECT_DIR" 2>/dev/null && \
|
|
8004
|
+
LOKI_TARGET_DIR="$target_dir" \
|
|
8005
|
+
timeout 5 python3 -m memory.managed_memory.retrieve \
|
|
8006
|
+
--query "$goal" --top-k 3 2>/dev/null || true
|
|
8007
|
+
)
|
|
8008
|
+
if [ -n "$managed_out" ]; then
|
|
8009
|
+
echo ""
|
|
8010
|
+
echo "RELATED PRIOR LEARNINGS (managed store):"
|
|
8011
|
+
echo "$managed_out"
|
|
8012
|
+
else
|
|
8013
|
+
# No output could mean: flags off (unreachable here), timeout, or
|
|
8014
|
+
# zero hits. Emit a fallback event only if a timeout likely occurred.
|
|
8015
|
+
LOKI_TARGET_DIR="$target_dir" \
|
|
8016
|
+
python3 -c "from memory.managed_memory.events import emit_managed_event; emit_managed_event('managed_memory_retrieve_empty', {'phase': '$phase'})" 2>/dev/null || true
|
|
8017
|
+
fi
|
|
8018
|
+
fi
|
|
7980
8019
|
}
|
|
7981
8020
|
|
|
7982
8021
|
# Store episode trace after task completion
|
|
@@ -8080,14 +8119,21 @@ auto_capture_episode() {
|
|
|
8080
8119
|
fi
|
|
8081
8120
|
|
|
8082
8121
|
# Pass all context via environment variables (prevents injection)
|
|
8122
|
+
# v6.83.0: also stash the resolved episode path so the bash caller can
|
|
8123
|
+
# optionally shadow-write it to the managed store if importance >= 0.6.
|
|
8124
|
+
local episode_path_file="/tmp/loki-episode-path-$$"
|
|
8125
|
+
: > "$episode_path_file"
|
|
8083
8126
|
_LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
|
|
8084
8127
|
_LOKI_ITERATION="$iteration" _LOKI_EXIT_CODE="$exit_code" \
|
|
8085
8128
|
_LOKI_RARV_PHASE="$rarv_phase" _LOKI_GOAL="$goal" \
|
|
8086
8129
|
_LOKI_DURATION="$duration" _LOKI_OUTCOME="$outcome" \
|
|
8087
8130
|
_LOKI_FILES_MODIFIED="$files_modified" _LOKI_GIT_COMMIT="$git_commit" \
|
|
8131
|
+
_LOKI_EPISODE_PATH_FILE="$episode_path_file" \
|
|
8088
8132
|
python3 << 'PYEOF' 2>/dev/null || true
|
|
8089
8133
|
import sys
|
|
8090
8134
|
import os
|
|
8135
|
+
import json
|
|
8136
|
+
from pathlib import Path
|
|
8091
8137
|
|
|
8092
8138
|
project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
|
|
8093
8139
|
target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')
|
|
@@ -8098,6 +8144,7 @@ duration = os.environ.get('_LOKI_DURATION', '0')
|
|
|
8098
8144
|
outcome = os.environ.get('_LOKI_OUTCOME', 'success')
|
|
8099
8145
|
files_modified = os.environ.get('_LOKI_FILES_MODIFIED', '')
|
|
8100
8146
|
git_commit = os.environ.get('_LOKI_GIT_COMMIT', '')
|
|
8147
|
+
path_out_file = os.environ.get('_LOKI_EPISODE_PATH_FILE', '')
|
|
8101
8148
|
|
|
8102
8149
|
sys.path.insert(0, project_dir)
|
|
8103
8150
|
try:
|
|
@@ -8120,9 +8167,47 @@ try:
|
|
|
8120
8167
|
trace.files_modified = [f for f in files_modified.split('|') if f] if files_modified else []
|
|
8121
8168
|
|
|
8122
8169
|
engine.store_episode(trace)
|
|
8170
|
+
|
|
8171
|
+
# v6.83.0: surface the on-disk episode path + importance so bash can
|
|
8172
|
+
# decide whether to shadow-write. Writing to a known file (not stdout)
|
|
8173
|
+
# keeps the existing stdout contract intact.
|
|
8174
|
+
try:
|
|
8175
|
+
importance = float(getattr(trace, 'importance', 0.0) or 0.0)
|
|
8176
|
+
except (TypeError, ValueError):
|
|
8177
|
+
importance = 0.0
|
|
8178
|
+
episode_file = Path(f'{target_dir}/.loki/memory/episodic') / f'{trace.id}.json'
|
|
8179
|
+
if path_out_file:
|
|
8180
|
+
try:
|
|
8181
|
+
with open(path_out_file, 'w', encoding='utf-8') as f:
|
|
8182
|
+
json.dump({'path': str(episode_file), 'importance': importance}, f)
|
|
8183
|
+
except OSError:
|
|
8184
|
+
pass
|
|
8123
8185
|
except Exception:
|
|
8124
8186
|
pass # Silently fail -- memory capture must never break the loop
|
|
8125
8187
|
PYEOF
|
|
8188
|
+
|
|
8189
|
+
# v6.83.0 Phase 1: RARV-C REFLECT/VERIFY shadow-write. Only when both
|
|
8190
|
+
# managed flags are on AND the episode meets the consolidation importance
|
|
8191
|
+
# threshold (>= 0.6). Fully non-blocking (backgrounded subprocess).
|
|
8192
|
+
if [ "$LOKI_MANAGED_AGENTS" = "true" ] && [ "$LOKI_MANAGED_MEMORY" = "true" ] \
|
|
8193
|
+
&& [ -s "$episode_path_file" ]; then
|
|
8194
|
+
local _ep_path _ep_imp
|
|
8195
|
+
_ep_path=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('path',''))" "$episode_path_file" 2>/dev/null || echo "")
|
|
8196
|
+
_ep_imp=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('importance',0.0))" "$episode_path_file" 2>/dev/null || echo "0")
|
|
8197
|
+
if [ -n "$_ep_path" ] && [ -f "$_ep_path" ]; then
|
|
8198
|
+
local _above_threshold
|
|
8199
|
+
_above_threshold=$(python3 -c "print('yes' if float('$_ep_imp') >= 0.6 else 'no')" 2>/dev/null || echo "no")
|
|
8200
|
+
if [ "$_above_threshold" = "yes" ]; then
|
|
8201
|
+
(
|
|
8202
|
+
cd "$PROJECT_DIR" 2>/dev/null && \
|
|
8203
|
+
LOKI_TARGET_DIR="$target_dir" \
|
|
8204
|
+
timeout 15 python3 -m memory.managed_memory.shadow_write --path "$_ep_path" >/dev/null 2>&1 || true
|
|
8205
|
+
) &
|
|
8206
|
+
disown 2>/dev/null || true
|
|
8207
|
+
fi
|
|
8208
|
+
fi
|
|
8209
|
+
fi
|
|
8210
|
+
rm -f "$episode_path_file" 2>/dev/null || true
|
|
8126
8211
|
}
|
|
8127
8212
|
|
|
8128
8213
|
# Run memory consolidation pipeline
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED
package/mcp/requirements.txt
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loki Managed Agents Memory package (v6.83.0 Phase 1).
|
|
3
|
+
|
|
4
|
+
Opt-in integration with Claude Managed Agents memory stores. All behavior in
|
|
5
|
+
this package is gated on the two environment variables:
|
|
6
|
+
|
|
7
|
+
LOKI_MANAGED_AGENTS parent switch (default: false)
|
|
8
|
+
LOKI_MANAGED_MEMORY child switch (default: false)
|
|
9
|
+
|
|
10
|
+
Both must be "true" for any API call to be issued. If the child is "true" while
|
|
11
|
+
the parent is "false", the loki runner fails fast at startup (see
|
|
12
|
+
autonomy/run.sh). If the flags are off, every exported function is a cheap
|
|
13
|
+
no-op -- importing this package will NOT trigger any network or SDK import
|
|
14
|
+
side-effects.
|
|
15
|
+
|
|
16
|
+
This package is intentionally the ONLY place in the codebase that imports the
|
|
17
|
+
`anthropic` SDK. A CI test (tests/managed_memory/test_sdk_isolation.sh)
|
|
18
|
+
enforces that invariant.
|
|
19
|
+
|
|
20
|
+
Exports:
|
|
21
|
+
is_enabled() - bool, True iff both flags are on
|
|
22
|
+
ManagedDisabled - raised when callers try to force an op while off
|
|
23
|
+
shadow_write_verdict(path) - shadow-write a council verdict JSON to the store
|
|
24
|
+
shadow_write_pattern(obj) - shadow-write a semantic pattern dict
|
|
25
|
+
retrieve_related_verdicts(q, top_k=3, store_id=None)
|
|
26
|
+
- return list of related prior verdicts
|
|
27
|
+
hydrate_patterns(mtime_floor)
|
|
28
|
+
- pull recent patterns and merge locally
|
|
29
|
+
probe_beta_header() - return the active beta header string
|
|
30
|
+
emit_managed_event(type, payload)
|
|
31
|
+
- low-level event writer
|
|
32
|
+
|
|
33
|
+
None of these functions raise on SDK or network errors. They return empty /
|
|
34
|
+
None and log one WARN line. Real SDK errors surface through
|
|
35
|
+
`.loki/managed/events.ndjson`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import os
|
|
41
|
+
from typing import Optional
|
|
42
|
+
|
|
43
|
+
from ._beta import BETA_HEADER
|
|
44
|
+
from .events import emit_managed_event
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ManagedDisabled(Exception):
|
|
48
|
+
"""Raised when a managed-memory operation is attempted while flags are off."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_enabled() -> bool:
|
|
52
|
+
"""True iff LOKI_MANAGED_AGENTS=true AND LOKI_MANAGED_MEMORY=true."""
|
|
53
|
+
parent = os.environ.get("LOKI_MANAGED_AGENTS", "").strip().lower() == "true"
|
|
54
|
+
child = os.environ.get("LOKI_MANAGED_MEMORY", "").strip().lower() == "true"
|
|
55
|
+
return parent and child
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def probe_beta_header() -> str:
|
|
59
|
+
"""Return the pinned managed-agents beta header."""
|
|
60
|
+
return BETA_HEADER
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Lazy re-exports. Importing the top-level package MUST NOT import the
|
|
64
|
+
# anthropic SDK, so we defer the real imports until the functions are called.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def shadow_write_verdict(verdict_json_path: str) -> None:
|
|
68
|
+
"""Shadow-write a council verdict file to the managed store (opt-in)."""
|
|
69
|
+
if not is_enabled():
|
|
70
|
+
return None
|
|
71
|
+
from . import shadow_write as _sw # local import: gated on flags
|
|
72
|
+
return _sw.shadow_write_verdict(verdict_json_path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def shadow_write_pattern(pattern: dict) -> None:
|
|
76
|
+
"""Shadow-write a semantic pattern dict to the managed store (opt-in)."""
|
|
77
|
+
if not is_enabled():
|
|
78
|
+
return None
|
|
79
|
+
from . import shadow_write as _sw
|
|
80
|
+
return _sw.shadow_write_pattern(pattern)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def retrieve_related_verdicts(
|
|
84
|
+
query: str,
|
|
85
|
+
top_k: int = 3,
|
|
86
|
+
store_id: Optional[str] = None,
|
|
87
|
+
):
|
|
88
|
+
"""Return related verdicts from the managed store; [] when disabled or on error."""
|
|
89
|
+
if not is_enabled():
|
|
90
|
+
return []
|
|
91
|
+
from . import retrieve as _r
|
|
92
|
+
return _r.retrieve_related_verdicts(query, top_k=top_k, store_id=store_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def hydrate_patterns(local_mtime_floor: float):
|
|
96
|
+
"""Pull semantic patterns updated after floor; no-op when disabled."""
|
|
97
|
+
if not is_enabled():
|
|
98
|
+
return None
|
|
99
|
+
from . import retrieve as _r
|
|
100
|
+
return _r.hydrate_patterns(local_mtime_floor)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"BETA_HEADER",
|
|
105
|
+
"ManagedDisabled",
|
|
106
|
+
"emit_managed_event",
|
|
107
|
+
"hydrate_patterns",
|
|
108
|
+
"is_enabled",
|
|
109
|
+
"probe_beta_header",
|
|
110
|
+
"retrieve_related_verdicts",
|
|
111
|
+
"shadow_write_pattern",
|
|
112
|
+
"shadow_write_verdict",
|
|
113
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loki Managed Agents Memory - Beta Header (v6.83.0 Phase 1).
|
|
3
|
+
|
|
4
|
+
Single source of truth for the anthropic-beta header required by Claude
|
|
5
|
+
Managed Agents. All callers in memory/managed_memory/ import BETA_HEADER
|
|
6
|
+
from here. Update this constant to roll to a new beta.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Pin to the public Managed Agents beta channel current as of v6.83.0.
|
|
10
|
+
# This value is sent as the `anthropic-beta` HTTP header on every request.
|
|
11
|
+
BETA_HEADER = "managed-agents-2026-04-01"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loki Managed Agents Memory - Client wrapper (v6.83.0 Phase 1).
|
|
3
|
+
|
|
4
|
+
Thin wrapper around the `anthropic` SDK. This is the ONLY file in the codebase
|
|
5
|
+
that imports `anthropic`. A CI invariant test enforces that.
|
|
6
|
+
|
|
7
|
+
The wrapper:
|
|
8
|
+
- Sets anthropic-beta: managed-agents-2026-04-01 on every request.
|
|
9
|
+
- Reads ANTHROPIC_API_KEY from env. Absence raises ManagedDisabled.
|
|
10
|
+
- Wraps every SDK call in a 10s hard timeout. Timeouts are treated as
|
|
11
|
+
recoverable: the caller decides whether to fall back.
|
|
12
|
+
- Never retries inside the client (no retry-storm). Callers implement
|
|
13
|
+
bounded retry (e.g. 409 precondition merge-and-retry-once).
|
|
14
|
+
|
|
15
|
+
NOTE on API surface: the exact Managed Agents memory endpoints are under a
|
|
16
|
+
beta channel. This wrapper implements a minimal, forward-compatible subset --
|
|
17
|
+
stores_list, stores_get_or_create, memory_create, memory_read, memories_list.
|
|
18
|
+
If the SDK version installed does not expose `beta.memory`, calls raise an
|
|
19
|
+
AttributeError which the callers translate into a ManagedDisabled/fallback.
|
|
20
|
+
|
|
21
|
+
Not tested end-to-end against a live ANTHROPIC_API_KEY in CI. Automated tests
|
|
22
|
+
use memory/managed_memory/fakes.py.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import os
|
|
29
|
+
import threading
|
|
30
|
+
from typing import Any, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
from . import ManagedDisabled
|
|
33
|
+
from ._beta import BETA_HEADER
|
|
34
|
+
|
|
35
|
+
_DEFAULT_TIMEOUT = 10.0 # seconds
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_flags_or_raise() -> None:
|
|
39
|
+
parent = os.environ.get("LOKI_MANAGED_AGENTS", "").strip().lower() == "true"
|
|
40
|
+
child = os.environ.get("LOKI_MANAGED_MEMORY", "").strip().lower() == "true"
|
|
41
|
+
if not (parent and child):
|
|
42
|
+
raise ManagedDisabled(
|
|
43
|
+
"managed memory flags are off "
|
|
44
|
+
"(LOKI_MANAGED_AGENTS and LOKI_MANAGED_MEMORY must both be 'true')"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _require_api_key() -> str:
|
|
49
|
+
key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
50
|
+
if not key:
|
|
51
|
+
raise ManagedDisabled("ANTHROPIC_API_KEY is not set")
|
|
52
|
+
return key
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_sha256(content: str) -> str:
|
|
56
|
+
"""Stable content hash used as an optimistic precondition on writes."""
|
|
57
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ManagedClient:
|
|
61
|
+
"""
|
|
62
|
+
Thin SDK wrapper. Instantiating this class imports anthropic and validates
|
|
63
|
+
credentials; callers should construct it lazily inside flag-gated paths.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
|
67
|
+
_check_flags_or_raise()
|
|
68
|
+
api_key = _require_api_key()
|
|
69
|
+
# Import lazily so the top-level package stays SDK-free.
|
|
70
|
+
try:
|
|
71
|
+
import anthropic # noqa: F401 (imported for side-effects + symbol)
|
|
72
|
+
except ImportError as e: # pragma: no cover - import surface
|
|
73
|
+
raise ManagedDisabled(f"anthropic SDK not installed: {e}")
|
|
74
|
+
|
|
75
|
+
self._anthropic = anthropic
|
|
76
|
+
self._client = anthropic.Anthropic(
|
|
77
|
+
api_key=api_key,
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
default_headers={"anthropic-beta": BETA_HEADER},
|
|
80
|
+
)
|
|
81
|
+
self._timeout = timeout
|
|
82
|
+
|
|
83
|
+
# ---------- helpers -------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _beta(self):
|
|
86
|
+
"""Return the beta namespace, if the SDK exposes it.
|
|
87
|
+
|
|
88
|
+
Newer SDK versions expose `client.beta.memory.*`. If the attribute
|
|
89
|
+
path is missing we raise ManagedDisabled so callers can fall back.
|
|
90
|
+
"""
|
|
91
|
+
beta = getattr(self._client, "beta", None)
|
|
92
|
+
if beta is None:
|
|
93
|
+
raise ManagedDisabled("anthropic SDK missing `beta` namespace")
|
|
94
|
+
return beta
|
|
95
|
+
|
|
96
|
+
# ---------- stores --------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def stores_list(self) -> List[Dict[str, Any]]:
|
|
99
|
+
"""List managed memory stores on this account (may be empty)."""
|
|
100
|
+
beta = self._beta()
|
|
101
|
+
stores = getattr(beta, "memory_stores", None) or getattr(beta, "stores", None)
|
|
102
|
+
if stores is None or not hasattr(stores, "list"):
|
|
103
|
+
raise ManagedDisabled("memory_stores API not available in SDK")
|
|
104
|
+
result = stores.list()
|
|
105
|
+
# SDK returns a pydantic model; normalize to list of dicts.
|
|
106
|
+
data = getattr(result, "data", result)
|
|
107
|
+
return [self._to_dict(x) for x in (data or [])]
|
|
108
|
+
|
|
109
|
+
def stores_get_or_create(
|
|
110
|
+
self, name: str, description: str = "", scope: str = "project"
|
|
111
|
+
) -> Dict[str, Any]:
|
|
112
|
+
"""Return existing store with `name` or create it."""
|
|
113
|
+
existing = [s for s in self.stores_list() if s.get("name") == name]
|
|
114
|
+
if existing:
|
|
115
|
+
return existing[0]
|
|
116
|
+
beta = self._beta()
|
|
117
|
+
stores = getattr(beta, "memory_stores", None) or getattr(beta, "stores", None)
|
|
118
|
+
if stores is None or not hasattr(stores, "create"):
|
|
119
|
+
raise ManagedDisabled("memory_stores.create not available in SDK")
|
|
120
|
+
created = stores.create(name=name, description=description, scope=scope)
|
|
121
|
+
return self._to_dict(created)
|
|
122
|
+
|
|
123
|
+
# ---------- memories ------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def memory_create(
|
|
126
|
+
self,
|
|
127
|
+
store_id: str,
|
|
128
|
+
path: str,
|
|
129
|
+
content: str,
|
|
130
|
+
sha256_precondition: Optional[str] = None,
|
|
131
|
+
) -> Dict[str, Any]:
|
|
132
|
+
"""
|
|
133
|
+
Create a memory entry at `path` in `store_id`.
|
|
134
|
+
|
|
135
|
+
When sha256_precondition is supplied, this is an optimistic
|
|
136
|
+
concurrency hint: if the store already holds a different hash the
|
|
137
|
+
SDK is expected to surface a 409-shaped error. Callers handle the
|
|
138
|
+
409 by re-reading, merging, and retrying once.
|
|
139
|
+
"""
|
|
140
|
+
beta = self._beta()
|
|
141
|
+
memories = getattr(beta, "memories", None)
|
|
142
|
+
if memories is None or not hasattr(memories, "create"):
|
|
143
|
+
raise ManagedDisabled("memories.create not available in SDK")
|
|
144
|
+
kwargs: Dict[str, Any] = {
|
|
145
|
+
"store_id": store_id,
|
|
146
|
+
"path": path,
|
|
147
|
+
"content": content,
|
|
148
|
+
}
|
|
149
|
+
if sha256_precondition:
|
|
150
|
+
kwargs["if_match_sha256"] = sha256_precondition
|
|
151
|
+
created = memories.create(**kwargs)
|
|
152
|
+
return self._to_dict(created)
|
|
153
|
+
|
|
154
|
+
def memory_read(self, store_id: str, memory_id: str) -> Dict[str, Any]:
|
|
155
|
+
beta = self._beta()
|
|
156
|
+
memories = getattr(beta, "memories", None)
|
|
157
|
+
if memories is None or not hasattr(memories, "retrieve"):
|
|
158
|
+
raise ManagedDisabled("memories.retrieve not available in SDK")
|
|
159
|
+
got = memories.retrieve(store_id=store_id, memory_id=memory_id)
|
|
160
|
+
return self._to_dict(got)
|
|
161
|
+
|
|
162
|
+
def memories_list(
|
|
163
|
+
self, store_id: str, path_prefix: Optional[str] = None
|
|
164
|
+
) -> List[Dict[str, Any]]:
|
|
165
|
+
beta = self._beta()
|
|
166
|
+
memories = getattr(beta, "memories", None)
|
|
167
|
+
if memories is None or not hasattr(memories, "list"):
|
|
168
|
+
raise ManagedDisabled("memories.list not available in SDK")
|
|
169
|
+
kwargs: Dict[str, Any] = {"store_id": store_id}
|
|
170
|
+
if path_prefix:
|
|
171
|
+
kwargs["path_prefix"] = path_prefix
|
|
172
|
+
result = memories.list(**kwargs)
|
|
173
|
+
data = getattr(result, "data", result)
|
|
174
|
+
return [self._to_dict(x) for x in (data or [])]
|
|
175
|
+
|
|
176
|
+
# ---------- internal ------------------------------------------------
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _to_dict(obj: Any) -> Dict[str, Any]:
|
|
180
|
+
"""Best-effort pydantic-or-dict to dict conversion."""
|
|
181
|
+
if isinstance(obj, dict):
|
|
182
|
+
return obj
|
|
183
|
+
to_dict = getattr(obj, "model_dump", None) or getattr(obj, "dict", None)
|
|
184
|
+
if callable(to_dict):
|
|
185
|
+
try:
|
|
186
|
+
return to_dict()
|
|
187
|
+
except TypeError:
|
|
188
|
+
return to_dict()
|
|
189
|
+
return {"raw": str(obj)}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Optional helper for callers that want a thread-safe singleton.
|
|
193
|
+
_singleton: Optional[ManagedClient] = None
|
|
194
|
+
_singleton_lock = threading.Lock()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_client() -> ManagedClient:
|
|
198
|
+
"""Return a lazily-constructed singleton. Raises ManagedDisabled if off."""
|
|
199
|
+
global _singleton
|
|
200
|
+
with _singleton_lock:
|
|
201
|
+
if _singleton is None:
|
|
202
|
+
_singleton = ManagedClient()
|
|
203
|
+
return _singleton
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def reset_client() -> None:
|
|
207
|
+
"""Test hook: drop the cached singleton so tests can swap implementations."""
|
|
208
|
+
global _singleton
|
|
209
|
+
with _singleton_lock:
|
|
210
|
+
_singleton = None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loki Managed Agents Memory - Event emission (v6.83.0 Phase 1).
|
|
3
|
+
|
|
4
|
+
Appends structured JSONL events to .loki/managed/events.ndjson. Single-writer
|
|
5
|
+
convention: only code in memory/managed_memory/ writes to this file. Rotates
|
|
6
|
+
when the file exceeds 10MB.
|
|
7
|
+
|
|
8
|
+
Events are used to record fallbacks, shadow-write successes/failures, and
|
|
9
|
+
retrieve hits. The file is safe to tail for observability during development.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
# 10 MB rotation threshold. Keeping rotation simple: rename to .<YYYYMMDD>.
|
|
21
|
+
_ROTATE_BYTES = 10 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _events_dir(target_dir: Optional[str] = None) -> Path:
|
|
25
|
+
base = target_dir or os.environ.get("LOKI_TARGET_DIR") or os.getcwd()
|
|
26
|
+
return Path(base) / ".loki" / "managed"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _maybe_rotate(path: Path) -> None:
|
|
30
|
+
"""Rotate the events file if it has exceeded the size threshold."""
|
|
31
|
+
try:
|
|
32
|
+
if path.exists() and path.stat().st_size >= _ROTATE_BYTES:
|
|
33
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
34
|
+
rotated = path.with_suffix(path.suffix + f".{stamp}")
|
|
35
|
+
# Best-effort rename; if another writer got there first, move on.
|
|
36
|
+
try:
|
|
37
|
+
path.rename(rotated)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
except OSError:
|
|
41
|
+
# If stat fails, skip rotation; next write will retry.
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def emit_managed_event(
|
|
46
|
+
event_type: str,
|
|
47
|
+
payload: Dict[str, Any],
|
|
48
|
+
target_dir: Optional[str] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Append a managed-memory event to .loki/managed/events.ndjson.
|
|
52
|
+
|
|
53
|
+
Never raises: on any I/O error the function silently returns. Callers
|
|
54
|
+
rely on this to keep the main RARV-C loop unblocked.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
event_type: short tag, e.g. "managed_agents_fallback",
|
|
58
|
+
"managed_memory_retrieve", "managed_memory_shadow_write".
|
|
59
|
+
payload: JSON-serializable context for the event.
|
|
60
|
+
target_dir: optional project root override. Defaults to
|
|
61
|
+
LOKI_TARGET_DIR env or cwd.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
dir_path = _events_dir(target_dir)
|
|
65
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path = dir_path / "events.ndjson"
|
|
67
|
+
_maybe_rotate(path)
|
|
68
|
+
|
|
69
|
+
record = {
|
|
70
|
+
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
71
|
+
"type": event_type,
|
|
72
|
+
"payload": payload,
|
|
73
|
+
}
|
|
74
|
+
# Line-buffered append; JSONL.
|
|
75
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
76
|
+
f.write(json.dumps(record, default=str) + "\n")
|
|
77
|
+
except Exception:
|
|
78
|
+
# Never raise from the event emitter.
|
|
79
|
+
return
|