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