Skip to content

events

strands_teams.events

Append-only typed event plane + deterministic replay + replan triggers (spec 006).

This module evolves the untyped team_debug.jsonl into a real, replayable event plane (ASTR §6.5, §7, §15.2). It ships three things, all built on the :class:~strands_teams.models.TeamEvent type:

  • :class:EventLog — an append-only log bound to a single TeamsLayout-resolved path (:meth:~strands_teams.layout.TeamsLayout.event_log_path). It exposes only append and read operations — no update or delete — so an already-written event can never be mutated (AC-2-5). append writes exactly one line and leaves every prior line byte-for-byte unchanged (AC-2-2); an OSError is logged and swallowed, never raised across the caller (AC-2-4), preserving the never-raises contract of the pre-split _write_debug_entry.
  • :func:replay — reconstructs the run's state transitions, artifacts, findings, and decisions from an EventLog alone, with no live model/Bedrock call (AC-3-1). Deterministic across two runs (AC-3-2); applies each event only after all its causal parents (AC-3-3); reflects superseding outcomes while retaining the superseded originals (AC-3-4); skips a malformed line with a recorded warning rather than raising (AC-3-5).
  • :func:should_replan — a predicate over the event log firing on any of the five ASTR replan triggers (AC-6-1..AC-6-7).

The event plane carries no chain-of-thought or raw transcript (plan §5; research-coding-runtimes.yaml line 99): a :class:~strands_teams.models.TeamEvent is extra="forbid", so a transcript field is rejected at construction.

REPLAN_TRIGGERS module-attribute

REPLAN_TRIGGERS = (
    TRIGGER_REQUIREMENT_CHANGED,
    TRIGGER_CRITICAL_FINDING,
    TRIGGER_TASK_FAILED,
    TRIGGER_BUDGET_EXCEEDED,
    TRIGGER_ARTIFACT_INTERFACE_CHANGED,
)

The five ASTR replan triggers, in canonical order.

EventLog

EventLog(path)

An append-only, replayable log of :class:~strands_teams.models.TeamEvent.

Bound to a single file path (resolved from :meth:~strands_teams.layout.TeamsLayout.event_log_path), writing one JSON-encoded event per line (AC-2-1). The API is deliberately append + read only — there is no update or delete method — so an already-written event is immutable (AC-2-5). Supersession is expressed by appending a new event, never by editing an old line (AC-3-4).

Bind the log to path (created lazily on first :meth:append).

Source code in packages/strands-teams/src/strands_teams/events.py
def __init__(self, path: Path) -> None:
    """Bind the log to ``path`` (created lazily on first :meth:`append`)."""
    self._path = path

path property

path

The file this log appends to and reads from.

append

append(event)

Append exactly one JSON line for event; never raise (AC-2-2, AC-2-4).

Serializes event to a single line via :meth:~pydantic.BaseModel.model_dump_json and appends it, leaving every previously written line byte-for-byte unchanged. An OSError (e.g. a read-only filesystem) is logged and swallowed so a log-write failure never kills the coordinator or the lead loop (AC-2-4), matching the never-raises contract of the pre-split _write_debug_entry (teams.py).

Source code in packages/strands-teams/src/strands_teams/events.py
def append(self, event: TeamEvent) -> None:
    """Append exactly one JSON line for ``event``; never raise (AC-2-2, AC-2-4).

    Serializes ``event`` to a single line via
    :meth:`~pydantic.BaseModel.model_dump_json` and appends it, leaving every
    previously written line byte-for-byte unchanged. An ``OSError`` (e.g. a
    read-only filesystem) is logged and swallowed so a log-write failure never
    kills the coordinator or the lead loop (AC-2-4), matching the never-raises
    contract of the pre-split ``_write_debug_entry`` (``teams.py``).
    """
    try:
        self._path.parent.mkdir(parents=True, exist_ok=True)
        line = event.model_dump_json()
        with self._path.open("a", encoding="utf-8") as f:
            f.write(line + "\n")
    except OSError as e:  # AC-2-4: log and continue, never raise.
        logger.debug(f"EventLog.append failed for {self._path}: {e}")

read

read()

Parse and return every valid event, skipping malformed lines.

A malformed line is skipped; use :func:replay when you need the malformed-line warnings surfaced structurally (AC-3-5).

Source code in packages/strands-teams/src/strands_teams/events.py
def read(self) -> list[TeamEvent]:
    """Parse and return every valid event, skipping malformed lines.

    A malformed line is skipped; use :func:`replay` when you need the
    malformed-line warnings surfaced structurally (AC-3-5).
    """
    events, _ = parse_lines(self._read_raw_lines())
    return events

read_with_warnings

read_with_warnings()

Return (events, warnings) — one warning per skipped malformed line.

Source code in packages/strands-teams/src/strands_teams/events.py
def read_with_warnings(self) -> tuple[list[TeamEvent], list[str]]:
    """Return ``(events, warnings)`` — one warning per skipped malformed line."""
    return parse_lines(self._read_raw_lines())

ReplayState

Bases: FrozenModel

The deterministic reconstruction produced by :func:replay (AC-3-1, AC-3-2).

Immutable. :meth:to_canonical_json yields a byte-stable serialization so two replays of the same log compare byte-for-byte identical (AC-3-2, AC-7-1).

Attributes:

Name Type Description
applied list[str]

event_ids in causal-apply order (AC-3-3).

transitions list[str]

event_types in causal-apply order — the state transitions.

artifacts dict[str, str]

artifact_binding -> event_id of the latest applied, non-superseded event touching that binding.

findings dict[str, dict[str, Any]]

Finding payloads reconstructed from finding-bearing events, keyed by event_id.

decisions dict[str, dict[str, Any]]

Decision payloads reconstructed from decision-bearing events, keyed by event_id.

superseded list[str]

event_ids reflected as superseded (originals retained in the log — supersession never deletes, AC-3-4).

warnings list[str]

One entry per malformed log line skipped during replay (AC-3-5).

to_canonical_json

to_canonical_json()

Return a byte-stable JSON serialization for cross-replay equality (AC-3-2).

Source code in packages/strands-teams/src/strands_teams/events.py
def to_canonical_json(self) -> str:
    """Return a byte-stable JSON serialization for cross-replay equality (AC-3-2)."""
    return json.dumps(self.model_dump(mode="json"), sort_keys=True, ensure_ascii=False)

ReplanDecision

Bases: FrozenModel

The structured outcome of :func:should_replan (AC-6-1).

Attributes:

Name Type Description
replan bool

Whether any replan trigger fired.

triggers list[str]

The distinct triggers that fired, in canonical order (empty when replan is False).

reasons list[str]

Human-readable reason per fired trigger.

parse_lines

parse_lines(lines)

Parse JSONL lines into events, collecting a warning per malformed line.

Deterministic: each line is validated as a :class:~strands_teams.models.TeamEvent; a line that is not valid JSON or not a valid event is skipped and a warning string (naming the 1-based line number) is recorded rather than raised (AC-3-5). Returns (events, warnings) in stable line order.

Source code in packages/strands-teams/src/strands_teams/events.py
def parse_lines(lines: Iterable[str]) -> tuple[list[TeamEvent], list[str]]:
    """Parse JSONL ``lines`` into events, collecting a warning per malformed line.

    Deterministic: each line is validated as a :class:`~strands_teams.models.TeamEvent`;
    a line that is not valid JSON or not a valid event is skipped and a warning
    string (naming the 1-based line number) is recorded rather than raised
    (AC-3-5). Returns ``(events, warnings)`` in stable line order.
    """
    events: list[TeamEvent] = []
    warnings: list[str] = []
    for i, line in enumerate(lines, start=1):
        try:
            events.append(TeamEvent.model_validate_json(line))
        except (ValueError, TypeError) as e:
            warnings.append(f"line {i}: skipped malformed event ({type(e).__name__})")
    return events, warnings

replay

replay(events, *, warnings=())

Reconstruct run state from events with no live model call (AC-3-1..AC-3-5).

Applies each event only after all its causal parents (:func:_causal_order, AC-3-3), records the transition sequence, tracks the latest non-superseded event per artifact binding, reconstructs finding and decision payloads, and reflects supersession (an event bearing epistemic_status="superseded" or an event naming another in its causal_parents with an epistemic_status in {accepted, rejected, superseded} supersedes that parent) while retaining the originals in the log (AC-3-4).

Deterministic: given the same events (and malformed-line warnings), the returned :class:ReplayState is identical across calls (AC-3-2).

Parameters:

Name Type Description Default
events Iterable[TeamEvent]

The parsed events to replay (typically EventLog.read()).

required
warnings Iterable[str]

Malformed-line warnings from parsing, folded into the result so :meth:ReplayState.to_canonical_json reflects them (AC-3-5).

()

Returns:

Name Type Description
A ReplayState

class:ReplayState reconstruction.

Source code in packages/strands-teams/src/strands_teams/events.py
def replay(events: Iterable[TeamEvent], *, warnings: Iterable[str] = ()) -> ReplayState:
    """Reconstruct run state from events with no live model call (AC-3-1..AC-3-5).

    Applies each event only after all its causal parents (:func:`_causal_order`,
    AC-3-3), records the transition sequence, tracks the latest non-superseded
    event per artifact binding, reconstructs finding and decision payloads, and
    reflects supersession (an event bearing ``epistemic_status="superseded"`` or an
    event naming another in its ``causal_parents`` with an
    ``epistemic_status`` in ``{accepted, rejected, superseded}`` supersedes that
    parent) while retaining the originals in the log (AC-3-4).

    Deterministic: given the same events (and malformed-line ``warnings``), the
    returned :class:`ReplayState` is identical across calls (AC-3-2).

    Args:
        events: The parsed events to replay (typically ``EventLog.read()``).
        warnings: Malformed-line warnings from parsing, folded into the result so
            :meth:`ReplayState.to_canonical_json` reflects them (AC-3-5).

    Returns:
        A :class:`ReplayState` reconstruction.
    """
    event_list = list(events)
    ordered, order_warnings = _causal_order(event_list)

    applied: list[str] = []
    transitions: list[str] = []
    artifacts: dict[str, str] = {}
    findings: dict[str, dict[str, Any]] = {}
    decisions: dict[str, dict[str, Any]] = {}
    superseded: set[str] = set()

    for event in ordered:
        applied.append(event.event_id)
        transitions.append(event.event_type)

        # Supersession (AC-3-4): a superseded-status event marks itself; an
        # accepted/rejected/superseded event supersedes the parents it references.
        if event.epistemic_status == "superseded":
            superseded.add(event.event_id)
        if event.epistemic_status in {"accepted", "rejected", "superseded"}:
            superseded.update(event.causal_parents)

        if _is_finding_event(event):
            findings[event.event_id] = dict(event.payload)
        if _is_decision_event(event):
            decisions[event.event_id] = dict(event.payload)

    # Artifacts: latest non-superseded event that touches each binding, in apply order.
    for event in ordered:
        if event.event_id in superseded:
            continue
        for binding in event.artifact_bindings:
            artifacts[binding] = event.event_id

    return ReplayState(
        applied=applied,
        transitions=transitions,
        artifacts=artifacts,
        findings=findings,
        decisions=decisions,
        superseded=sorted(superseded),
        warnings=[*warnings, *order_warnings],
    )

replay_log

replay_log(log)

Replay an :class:EventLog end to end, surfacing malformed-line warnings (AC-3-5).

Source code in packages/strands-teams/src/strands_teams/events.py
def replay_log(log: EventLog) -> ReplayState:
    """Replay an :class:`EventLog` end to end, surfacing malformed-line warnings (AC-3-5)."""
    events, warnings = log.read_with_warnings()
    return replay(events, warnings=warnings)

should_replan

should_replan(events, *, budget_threshold=0.0)

Decide whether the lead loop should replan (AC-6-1..AC-6-7).

Scans the event log for any of the five ASTR triggers — requirement.changed, critical_finding.created, task.failed, budget.threshold_exceeded, artifact.interface_changed — matching on either an explicit trigger-typed event or the structured payload of a generic dispatch/finding/revision event. Cumulative recorded cost crossing budget_threshold fires the budget trigger (AC-6-4). Returns replan=False with no triggers on a trigger-free log (AC-6-7).

Parameters:

Name Type Description Default
events Iterable[TeamEvent]

The events to evaluate (typically EventLog.read()).

required
budget_threshold float

The configured cumulative-cost bound; 0 disables the cumulative budget check (an explicit budget.threshold_exceeded event still fires).

0.0

Returns:

Name Type Description
A ReplanDecision

class:ReplanDecision naming the distinct triggers that fired.

Source code in packages/strands-teams/src/strands_teams/events.py
def should_replan(events: Iterable[TeamEvent], *, budget_threshold: float = 0.0) -> ReplanDecision:
    """Decide whether the lead loop should replan (AC-6-1..AC-6-7).

    Scans the event log for any of the five ASTR triggers — ``requirement.changed``,
    ``critical_finding.created``, ``task.failed``, ``budget.threshold_exceeded``,
    ``artifact.interface_changed`` — matching on either an explicit trigger-typed
    event or the structured payload of a generic dispatch/finding/revision event.
    Cumulative recorded cost crossing ``budget_threshold`` fires the budget trigger
    (AC-6-4). Returns ``replan=False`` with no triggers on a trigger-free log
    (AC-6-7).

    Args:
        events: The events to evaluate (typically ``EventLog.read()``).
        budget_threshold: The configured cumulative-cost bound; ``0`` disables the
            cumulative budget check (an explicit ``budget.threshold_exceeded`` event
            still fires).

    Returns:
        A :class:`ReplanDecision` naming the distinct triggers that fired.
    """
    event_list = list(events)
    fired: dict[str, str] = {}

    for event in event_list:
        hit = _event_fires(event)
        if hit is not None:
            trigger, reason = hit
            fired.setdefault(trigger, reason)

    if _budget_exceeded(event_list, budget_threshold=budget_threshold):
        fired.setdefault(TRIGGER_BUDGET_EXCEEDED, f"cumulative cost crossed threshold {budget_threshold}")

    ordered = [t for t in REPLAN_TRIGGERS if t in fired]
    return ReplanDecision(
        replan=bool(ordered),
        triggers=ordered,
        reasons=[fired[t] for t in ordered],
    )