agent_loop (src/agent_loop/)

Purpose: Start a fresh agent run from new prompt messages. Preconditions: prompts is non-empty; context.messages may contain prior history. Postconditions: All input filters have run; AgentStart/AgentEnd are emitted; returns all new messages produced.

FUNCTION agent_loop(
  prompts: Vec<AgentMessage>,
  context: AgentContext,         // mutable
  config: AgentLoopConfig,
  tx: EventChannel<AgentEvent>,
  cancel: CancellationToken
) -> Vec<AgentMessage>

  // ── loop_id generation (must happen before before_loop so AgentEnd can carry it) ──
  IF context.loop_id is None THEN context.loop_id ← new_uuid() END IF

  // ── before_loop hook ────────────────────────────────────────────────────
  // Fires before AgentStart. Return false to abort before the loop begins.
  IF config.before_loop defined AND NOT before_loop(context.messages, 0) THEN
    EMIT AgentEnd(loop_id=context.loop_id, messages=[])
    RETURN []
  END IF

  // ── Identity write-back ──────────────────────────────────────────────────
  // agent_id / session_id are set by Agent::prompt_*. Direct callers may leave
  // them None; agent_loop generates and writes them back so that a subsequent
  // agent_loop_continue on the same context can inherit them without extra setup.
  IF context.agent_id is None THEN context.agent_id ← new_uuid() END IF
  IF context.session_id is None THEN context.session_id ← new_uuid() END IF

  EMIT AgentStart {
    agent_id:          context.agent_id,
    session_id:        context.session_id,
    loop_id:           context.loop_id,
    parent_loop_id:    None,    // None = origin call
    continuation_kind: Initial, // Initial = origin call (the #[default])
    config_snapshot:   Some(LoopConfigSnapshot from config),
    timestamp:         now()
  }

  // ── Input filtering ─────────────────────────────────────────────────────
  IF config.input_filters is non-empty THEN
    user_text ← JOIN all text from User messages in prompts

    warnings ← []
    FOR EACH filter IN config.input_filters
      MATCH filter.filter(user_text)
        CASE Pass     → continue
        CASE Warn(w)  → warnings.append(w)
        CASE Reject(reason) →
          EMIT InputRejected(reason)
          EMIT AgentEnd(messages=[])
          RETURN []
      END MATCH
    END FOR

    IF warnings is non-empty THEN
      warning_text ← JOIN ["[Warning: " + w + "]" FOR w IN warnings]
      // Append to last User message's content
      append Content::Text(warning_text) to last User message in prompts
    END IF
  END IF

  // ── Append prompts to context ────────────────────────────────────────────
  FOR EACH prompt IN prompts
    context.messages.append(prompt)
  END FOR

  new_messages ← copy of prompts

  EMIT TurnStart

  // Emit events for each incoming prompt
  FOR EACH prompt IN prompts
    EMIT MessageStart(prompt)
    EMIT MessageEnd(prompt)
  END FOR

  // Run the main loop
  loop_usage ← run_loop(context, new_messages, config, tx, cancel)

  EMIT AgentEnd(new_messages)

  // ── after_loop hook ──────────────────────────────────────────────────────
  // Fires after AgentEnd with the messages produced and accumulated usage.
  IF config.after_loop defined THEN after_loop(new_messages, loop_usage) END IF

  RETURN new_messages

END FUNCTION

agent_loop_continue (src/agent_loop/)

Purpose: Resume an agent run from existing context (no new prompts, continue from last user/tool-result message). Preconditions: context.messages is non-empty; last message is NOT an assistant message; context.agent_id and context.session_id are Some. Postconditions: Same as agent_loop.

FUNCTION agent_loop_continue(
  context: AgentContext,         // mutable
  config: AgentLoopConfig,
  tx: EventChannel<AgentEvent>,
  cancel: CancellationToken
) -> Vec<AgentMessage>

  [invariant: context.messages is non-empty]
  [invariant: context.messages.last().role != "assistant"]
  // Identity must carry over from the originating loop.
  // These are set by Agent::continue_loop_with_sender (or the direct caller who
  // bootstrapped the session). Silent UUID generation here would break traceability.
  [invariant: context.agent_id is Some]
  [invariant: context.session_id is Some]

  new_messages ← []

  // ── Classify existing messages into 2-stream model (if not already populated) ──
  IF context.user_context is empty AND context.inrun_context is empty THEN
    FOR EACH msg IN context.messages
      IF msg is User         → context.user_context.append(msg)
      IF msg is Assistant or ToolResult → context.inrun_context.append(Live(msg))
      // Extension messages go to neither stream
    END FOR
  END IF

  // ── before_loop hook ────────────────────────────────────────────────────
  IF config.before_loop defined AND NOT before_loop(context.messages, 0) THEN
    EMIT AgentEnd(messages=[])
    RETURN []
  END IF

  EMIT AgentStart {
    agent_id:          context.agent_id.unwrap(),
    session_id:        context.session_id.unwrap(),
    loop_id:           context.loop_id OR new_uuid(),
    parent_loop_id:    context.parent_loop_id,    // None for Default, Some for Rerun/Branch
    continuation_kind: context.continuation_kind,  // Default|Rerun|Branch|Compaction (ContinuationKind, not Option)
    config_snapshot:   Some(LoopConfigSnapshot from config),
    timestamp:         now()
  }

  loop_usage ← run_loop(context, new_messages, config, tx, cancel)

  EMIT AgentEnd(new_messages)

  // ── after_loop hook ──────────────────────────────────────────────────────
  IF config.after_loop defined THEN after_loop(new_messages, loop_usage) END IF

  RETURN new_messages

END FUNCTION