File size: 6,841 Bytes
599eb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915c925
 
 
 
 
 
 
 
 
 
 
 
599eb60
 
 
 
 
 
 
 
 
 
 
915c925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599eb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915c925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599eb60
 
 
 
 
 
915c925
 
599eb60
 
 
 
 
 
 
 
 
 
 
915c925
 
 
 
 
 
 
 
 
 
 
 
 
599eb60
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
"""
memory/write.py β€” Per-decision write path.

Key rules (from brief Β§6):
- Vector entry (decisions row) and any related causal_edges rows from the
  same decision MUST be written in a single transaction β€” no partial writes.
- credit_label starts NULL and is backfilled by credit_assignment.py post-episode.
- quarantine_reason must be the gate's actual textual reason, not just the flag.
"""
from __future__ import annotations

import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional

import structlog
from sqlalchemy import select

from memory.db import get_db_session
from memory.models import CausalEdge, Decision, Episode

log = structlog.get_logger(__name__)


# ── Episode lifecycle ─────────────────────────────────────────────────────────

async def open_episode(
    episode_id: str,
    task_id: str,
    agent_version: str,
    started_at: float,
) -> None:
    """Create the episode row at the start of an episode."""
    started_dt = datetime.utcfromtimestamp(started_at)
    try:
        async with get_db_session() as session:
            episode = Episode(
                episode_id=episode_id,
                task_id=task_id,
                agent_version=agent_version,
                started_at=started_dt,
            )
            session.add(episode)
        log.info("memory.episode.opened", episode_id=episode_id, task_id=task_id)
    except Exception as exc:
        log.warning("memory.db_unavailable", op="open_episode", error=str(exc), note="Skipping DB write, continuing in-memory")


async def close_episode(
    episode_id: str,
    outcome: str,
    final_reward: float,
    resolution_summary: str,
    ended_at: float,
) -> None:
    """Update episode row with outcome after it completes."""
    ended_dt = datetime.utcfromtimestamp(ended_at)
    try:
        async with get_db_session() as session:
            result = await session.execute(
                select(Episode).where(Episode.episode_id == episode_id)
            )
            episode = result.scalar_one_or_none()
            if episode is None:
                log.warning("memory.episode.not_found", episode_id=episode_id)
                return
            episode.ended_at = ended_dt
            episode.outcome = outcome
            episode.final_reward = final_reward
            episode.resolution_summary = resolution_summary
            session.add(episode)
        log.info("memory.episode.closed", episode_id=episode_id, outcome=outcome, reward=final_reward)
    except Exception as exc:
        log.warning("memory.db_unavailable", op="close_episode", error=str(exc), note="Skipping DB write")


# ── Per-decision write ────────────────────────────────────────────────────────

async def write_decision(
    episode_id: str,
    step_index: int,
    state_signature: str,
    state_embedding: Optional[List[float]],
    action_type: str,
    action_payload: Dict[str, Any],
    result_stdout: Optional[str] = None,
    result_stderr: Optional[str] = None,
    exit_code: Optional[int] = None,
    quarantine_flag: bool = False,
    no_match_flag: bool = False,
    quarantine_reason: Optional[str] = None,
    causal_edges: Optional[List[Dict[str, Any]]] = None,
) -> str:
    """
    Write a decision row + any causal edges in a SINGLE transaction.
    Returns the decision_id for use in subsequent edge writes.

    causal_edges: list of dicts with keys:
      - to_decision (str, UUID of subsequent decision β€” may be None if not yet known)
      - relation_type (str): 'caused' | 'preceded' | 'remediated_by'
      - confidence (float): default 1.0
    Note: edges where to_decision is not yet known are written with from_decision
    and the to_decision is backfilled by the caller after the next decision is written.
    """
    decision_id = str(uuid.uuid4())

    try:
        async with get_db_session() as session:
            decision = Decision(
                decision_id=decision_id,
                episode_id=episode_id,
                step_index=step_index,
                state_signature=state_signature,
                state_embedding=state_embedding,
                action_type=action_type,
                action_payload=action_payload,
                result_stdout=result_stdout,
                result_stderr=result_stderr,
                exit_code=exit_code,
                quarantine_flag=quarantine_flag,
                no_match_flag=no_match_flag,
                quarantine_reason=quarantine_reason,
                # credit_label starts NULL β€” backfilled post-episode
            )
            session.add(decision)

            # Write causal edges in the SAME transaction
            if causal_edges:
                for edge_spec in causal_edges:
                    to_decision = edge_spec.get("to_decision")
                    if to_decision is None:
                        continue  # Skip incomplete edges; caller backfills
                    edge = CausalEdge(
                        edge_id=str(uuid.uuid4()),
                        from_decision=decision_id,
                        to_decision=to_decision,
                        relation_type=edge_spec.get("relation_type", "preceded"),
                        confidence=float(edge_spec.get("confidence", 1.0)),
                        last_reinforced=datetime.utcnow(),
                    )
                    session.add(edge)

        log.debug(
            "memory.decision.written",
            decision_id=decision_id,
            episode_id=episode_id,
            step_index=step_index,
            action_type=action_type,
            quarantine_flag=quarantine_flag,
        )
    except Exception as exc:
        log.warning("memory.db_unavailable", op="write_decision", error=str(exc), note="Skipping DB write")
    return decision_id


async def write_causal_edge(
    from_decision: str,
    to_decision: str,
    relation_type: str = "preceded",
    confidence: float = 1.0,
) -> str:
    """Write a single causal edge (used when to_decision becomes known after the fact)."""
    edge_id = str(uuid.uuid4())
    try:
        async with get_db_session() as session:
            edge = CausalEdge(
                edge_id=edge_id,
                from_decision=from_decision,
                to_decision=to_decision,
                relation_type=relation_type,
                confidence=confidence,
                last_reinforced=datetime.utcnow(),
            )
            session.add(edge)
    except Exception as exc:
        log.warning("memory.db_unavailable", op="write_causal_edge", error=str(exc))
    return edge_id