- lib-state.sh: all 7 functions now use os.environ instead of string interpolation - deploy.sh: syntax checker uses sys.argv[1] instead of '$f' interpolation - research-session.sh: per-command auth header instead of credential helper, tweet parsers use sys.argv instead of '$OUTFILE' interpolation - state_end_session: now writes pr_number to session JSON via env var Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
281 lines
8 KiB
Bash
Executable file
281 lines
8 KiB
Bash
Executable file
#!/bin/bash
|
|
# lib-state.sh — Bash helpers for reading/writing agent state files.
|
|
# Source this in pipeline scripts: source ops/agent-state/lib-state.sh
|
|
#
|
|
# All writes use atomic rename (write to .tmp, then mv) to prevent corruption.
|
|
# All reads return valid JSON or empty string on missing/corrupt files.
|
|
|
|
STATE_ROOT="${TELEO_STATE_ROOT:-/opt/teleo-eval/agent-state}"
|
|
|
|
# --- Internal helpers ---
|
|
|
|
_state_dir() {
|
|
local agent="$1"
|
|
echo "$STATE_ROOT/$agent"
|
|
}
|
|
|
|
# --- Report (current status) ---
|
|
|
|
state_read_report() {
|
|
local agent="$1"
|
|
local file="$(_state_dir "$agent")/report.json"
|
|
[ -f "$file" ] && cat "$file" || echo "{}"
|
|
}
|
|
|
|
state_update_report() {
|
|
local agent="$1"
|
|
local status="$2"
|
|
local summary="$3"
|
|
local file="$(_state_dir "$agent")/report.json"
|
|
|
|
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_STATUS="$status" \
|
|
_STATE_SUMMARY="$summary" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
python3 -c "
|
|
import json, os
|
|
try:
|
|
with open(os.environ['_STATE_FILE']) as f:
|
|
data = json.load(f)
|
|
except:
|
|
data = {'agent': os.environ['_STATE_AGENT']}
|
|
data['status'] = os.environ['_STATE_STATUS']
|
|
data['summary'] = os.environ['_STATE_SUMMARY']
|
|
data['updated_at'] = os.environ['_STATE_TS']
|
|
print(json.dumps(data, indent=2))
|
|
" | _atomic_write_stdin "$file"
|
|
}
|
|
|
|
# Variant that takes full JSON from stdin
|
|
_atomic_write_stdin() {
|
|
local filepath="$1"
|
|
local tmpfile="${filepath}.tmp.$$"
|
|
cat > "$tmpfile"
|
|
mv -f "$tmpfile" "$filepath"
|
|
}
|
|
|
|
# Full report update with session info (called at session end)
|
|
state_finalize_report() {
|
|
local agent="$1"
|
|
local status="$2"
|
|
local summary="$3"
|
|
local session_id="$4"
|
|
local started_at="$5"
|
|
local ended_at="$6"
|
|
local outcome="$7"
|
|
local sources="$8"
|
|
local branch="$9"
|
|
local pr_number="${10}"
|
|
local next_priority="${11:-null}"
|
|
local file="$(_state_dir "$agent")/report.json"
|
|
|
|
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_STATUS="$status" \
|
|
_STATE_SUMMARY="$summary" _STATE_SESSION_ID="$session_id" \
|
|
_STATE_STARTED="$started_at" _STATE_ENDED="$ended_at" \
|
|
_STATE_OUTCOME="$outcome" _STATE_SOURCES="$sources" \
|
|
_STATE_BRANCH="$branch" _STATE_PR="$pr_number" \
|
|
_STATE_NEXT="$next_priority" \
|
|
python3 -c "
|
|
import json, os
|
|
e = os.environ
|
|
sources = int(e['_STATE_SOURCES']) if e['_STATE_SOURCES'].isdigit() else 0
|
|
pr = int(e['_STATE_PR']) if e['_STATE_PR'].isdigit() else None
|
|
next_p = None if e['_STATE_NEXT'] == 'null' else e['_STATE_NEXT']
|
|
data = {
|
|
'agent': e['_STATE_AGENT'],
|
|
'updated_at': e['_STATE_ENDED'],
|
|
'status': e['_STATE_STATUS'],
|
|
'summary': e['_STATE_SUMMARY'],
|
|
'current_task': None,
|
|
'last_session': {
|
|
'id': e['_STATE_SESSION_ID'],
|
|
'started_at': e['_STATE_STARTED'],
|
|
'ended_at': e['_STATE_ENDED'],
|
|
'outcome': e['_STATE_OUTCOME'],
|
|
'sources_archived': sources,
|
|
'branch': e['_STATE_BRANCH'],
|
|
'pr_number': pr
|
|
},
|
|
'blocked_by': None,
|
|
'next_priority': next_p
|
|
}
|
|
print(json.dumps(data, indent=2))
|
|
" | _atomic_write_stdin "$file"
|
|
}
|
|
|
|
# --- Session ---
|
|
|
|
state_start_session() {
|
|
local agent="$1"
|
|
local session_id="$2"
|
|
local type="$3"
|
|
local domain="$4"
|
|
local branch="$5"
|
|
local model="${6:-sonnet}"
|
|
local timeout="${7:-5400}"
|
|
local started_at
|
|
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
local file="$(_state_dir "$agent")/session.json"
|
|
|
|
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_SID="$session_id" \
|
|
_STATE_STARTED="$started_at" _STATE_TYPE="$type" _STATE_DOMAIN="$domain" \
|
|
_STATE_BRANCH="$branch" _STATE_MODEL="$model" _STATE_TIMEOUT="$timeout" \
|
|
python3 -c "
|
|
import json, os
|
|
e = os.environ
|
|
data = {
|
|
'agent': e['_STATE_AGENT'],
|
|
'session_id': e['_STATE_SID'],
|
|
'started_at': e['_STATE_STARTED'],
|
|
'ended_at': None,
|
|
'type': e['_STATE_TYPE'],
|
|
'domain': e['_STATE_DOMAIN'],
|
|
'branch': e['_STATE_BRANCH'],
|
|
'status': 'running',
|
|
'model': e['_STATE_MODEL'],
|
|
'timeout_seconds': int(e['_STATE_TIMEOUT']),
|
|
'research_question': None,
|
|
'belief_targeted': None,
|
|
'disconfirmation_target': None,
|
|
'sources_archived': 0,
|
|
'sources_expected': 0,
|
|
'tokens_used': None,
|
|
'cost_usd': None,
|
|
'errors': [],
|
|
'handoff_notes': None
|
|
}
|
|
print(json.dumps(data, indent=2))
|
|
" | _atomic_write_stdin "$file"
|
|
|
|
echo "$started_at"
|
|
}
|
|
|
|
state_end_session() {
|
|
local agent="$1"
|
|
local outcome="$2"
|
|
local sources="${3:-0}"
|
|
local pr_number="${4:-null}"
|
|
local file="$(_state_dir "$agent")/session.json"
|
|
|
|
_STATE_FILE="$file" _STATE_OUTCOME="$outcome" _STATE_SOURCES="$sources" \
|
|
_STATE_PR="$pr_number" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
python3 -c "
|
|
import json, os
|
|
e = os.environ
|
|
with open(e['_STATE_FILE']) as f:
|
|
data = json.load(f)
|
|
data['ended_at'] = e['_STATE_TS']
|
|
data['status'] = e['_STATE_OUTCOME']
|
|
data['sources_archived'] = int(e['_STATE_SOURCES']) if e['_STATE_SOURCES'].isdigit() else 0
|
|
pr = e.get('_STATE_PR', 'null')
|
|
data['pr_number'] = int(pr) if pr.isdigit() else None
|
|
print(json.dumps(data, indent=2))
|
|
" | _atomic_write_stdin "$file"
|
|
}
|
|
|
|
# --- Journal (append-only JSONL) ---
|
|
|
|
state_journal_append() {
|
|
local agent="$1"
|
|
local event="$2"
|
|
shift 2
|
|
# Remaining args are key=value pairs for extra fields
|
|
local file="$(_state_dir "$agent")/journal.jsonl"
|
|
|
|
_STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" _STATE_EVT="$event" \
|
|
python3 -c "
|
|
import json, os, sys
|
|
entry = {'ts': os.environ['_STATE_TS'], 'event': os.environ['_STATE_EVT']}
|
|
for pair in sys.argv[1:]:
|
|
k, _, v = pair.partition('=')
|
|
if k:
|
|
entry[k] = v
|
|
print(json.dumps(entry))
|
|
" "$@" >> "$file"
|
|
}
|
|
|
|
# --- Metrics ---
|
|
|
|
state_update_metrics() {
|
|
local agent="$1"
|
|
local outcome="$2"
|
|
local sources="${3:-0}"
|
|
local file="$(_state_dir "$agent")/metrics.json"
|
|
|
|
_STATE_FILE="$file" _STATE_AGENT="$agent" _STATE_OUTCOME="$outcome" \
|
|
_STATE_SOURCES="$sources" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
python3 -c "
|
|
import json, os
|
|
e = os.environ
|
|
try:
|
|
with open(e['_STATE_FILE']) as f:
|
|
data = json.load(f)
|
|
except:
|
|
data = {'agent': e['_STATE_AGENT'], 'lifetime': {}, 'rolling_30d': {}}
|
|
|
|
lt = data.setdefault('lifetime', {})
|
|
lt['sessions_total'] = lt.get('sessions_total', 0) + 1
|
|
outcome = e['_STATE_OUTCOME']
|
|
if outcome == 'completed':
|
|
lt['sessions_completed'] = lt.get('sessions_completed', 0) + 1
|
|
elif outcome == 'timeout':
|
|
lt['sessions_timeout'] = lt.get('sessions_timeout', 0) + 1
|
|
elif outcome == 'error':
|
|
lt['sessions_error'] = lt.get('sessions_error', 0) + 1
|
|
lt['sources_archived'] = lt.get('sources_archived', 0) + (int(e['_STATE_SOURCES']) if e['_STATE_SOURCES'].isdigit() else 0)
|
|
|
|
data['updated_at'] = e['_STATE_TS']
|
|
print(json.dumps(data, indent=2))
|
|
" | _atomic_write_stdin "$file"
|
|
}
|
|
|
|
# --- Inbox ---
|
|
|
|
state_check_inbox() {
|
|
local agent="$1"
|
|
local inbox="$(_state_dir "$agent")/inbox"
|
|
[ -d "$inbox" ] && ls "$inbox"/*.json 2>/dev/null || true
|
|
}
|
|
|
|
state_send_message() {
|
|
local from="$1"
|
|
local to="$2"
|
|
local type="$3"
|
|
local subject="$4"
|
|
local body="$5"
|
|
local inbox="$(_state_dir "$to")/inbox"
|
|
local msg_id="msg-$(date +%s)-$$"
|
|
local file="$inbox/${msg_id}.json"
|
|
|
|
mkdir -p "$inbox"
|
|
_STATE_FILE="$file" _STATE_MSGID="$msg_id" _STATE_FROM="$from" \
|
|
_STATE_TO="$to" _STATE_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
_STATE_TYPE="$type" _STATE_SUBJECT="$subject" _STATE_BODY="$body" \
|
|
python3 -c "
|
|
import json, os
|
|
e = os.environ
|
|
data = {
|
|
'id': e['_STATE_MSGID'],
|
|
'from': e['_STATE_FROM'],
|
|
'to': e['_STATE_TO'],
|
|
'created_at': e['_STATE_TS'],
|
|
'type': e['_STATE_TYPE'],
|
|
'priority': 'normal',
|
|
'subject': e['_STATE_SUBJECT'],
|
|
'body': e['_STATE_BODY'],
|
|
'source_ref': None,
|
|
'expires_at': None
|
|
}
|
|
print(json.dumps(data, indent=2))
|
|
" | _atomic_write_stdin "$file"
|
|
echo "$msg_id"
|
|
}
|
|
|
|
# --- State directory check ---
|
|
|
|
state_ensure_dir() {
|
|
local agent="$1"
|
|
local dir="$(_state_dir "$agent")"
|
|
if [ ! -d "$dir" ]; then
|
|
echo "ERROR: Agent state not initialized for $agent. Run bootstrap.sh first." >&2
|
|
return 1
|
|
fi
|
|
}
|