phi-core 0.10.0

Simple, effective agent loop with tool execution and event streaming
Documentation
//! Model-invocable tool for Composition I "braking": tree-structured revert
//! between turns. See `docs/concepts/concept-brake.md` §5.
//
// ARCHITECTURE: RevertTool — model-directed branch abandonment via deferred execution
//
// Mirrors the deferred-apply pattern from `PrunTool` (see `tools/prun.rs`): the
// tool's `execute()` validates input and enqueues a `RevertRequest` on a shared
// `Arc<Mutex<Vec<_>>>`; the agent loop drains the queue between turns and applies
// each request via `apply_revert` (Phase 3). The tool itself does NOT mutate
// `AgentContext`.
//
// Why deferred (same reasoning as `PrunTool`):
//   1. Ownership — tools see `&self`; the active-node-id and message tree are
//      owned by the agent loop. Threading `&mut AgentContext` through
//      `ToolContext` would defeat parallel tool execution.
//   2. Timing — mid-turn mutation while the LLM stream is open would corrupt
//      content_index counters in `StreamEvent` deltas. Between-turn application
//      is the only safe window.
//   3. Auditing — every drained `RevertRequest` produces an `AgentEvent::RevertApplied`
//      (Phase 3) which the session recorder auto-persists; the abandoned span
//      lives forever in the forensic `messages` log and is only off-trunk.
//
// Opt-in guarantee: `RevertTool` is registered exclusively by
// `BasicAgent::with_revert_tool()`. The LLM never sees the tool unless the
// builder explicitly enables it; the apply_revert drain is gated on
// `AgentLoopConfig.revert_pending.is_some()`, which is set only by the same
// builder method. There is no other registration path.

use crate::types::{
    AgentTool, Content, NodeId, RevertCategory, ToolContext, ToolError, ToolResult,
};
use std::sync::{Arc, Mutex};

/// A pending revert request the LLM submitted via `revert_to_state`.
///
/// Lifecycle:
/// 1. [`RevertTool::execute`] pushes one of these onto the shared queue.
/// 2. The agent loop drains the queue between turns and calls `apply_revert`
///    on each (Phase 3).
/// 3. `apply_revert` validates the target, moves `AgentContext.active_node_id`,
///    attaches a [`NodeTag`](crate::types::NodeTag) carrying `summary`, and emits
///    `AgentEvent::RevertApplied` with the structured outcome.
#[derive(Debug, Clone)]
pub struct RevertRequest {
    /// Which of the four categories the agent chose — drives the resulting
    /// [`TagKind`](crate::types::TagKind) and the kind-aware render policy.
    pub category: RevertCategory,
    /// The [`NodeId`] the agent wants to revert to. The abandoned span is
    /// everything strictly after this node on the current trunk.
    pub target: NodeId,
    /// Agent-supplied one-line summary that becomes the
    /// [`NodeTag::text`](crate::types::NodeTag::text) attached to the target
    /// node. `None` is structurally valid — `apply_revert` attaches an empty
    /// tag — and reserved for a future fallback generator.
    pub summary: Option<String>,
}

/// Structured metadata persisted in the `details` field of the synthetic
/// `revert_to_state` `ToolResult`, and (mirroring `PrunRecord`) carried into
/// the `AgentEvent::RevertApplied` payload by `apply_revert`.
///
/// Source-of-truth for revert observability: a session replay can reconstruct
/// exactly which branch was abandoned, what category the agent assigned, and
/// what summary it wrote.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RevertRecord {
    /// Category the agent assigned at call time.
    pub category: RevertCategory,
    /// Target node — the new active-node-id after the revert.
    pub target: NodeId,
    /// The `node_id`s of every message that fell off-trunk as a result of the
    /// revert. Populated by `apply_revert` (Phase 3); empty at enqueue time.
    pub abandoned_node_ids: Vec<NodeId>,
    /// Echo of `RevertRequest.summary`.
    pub summary: Option<String>,
}

/// Model-invocable tool that enqueues a revert request between turns.
///
/// Construction is gated by [`BasicAgent::with_revert_tool`](crate::agents::BasicAgent::with_revert_tool);
/// the tool struct itself is `pub` so that custom agents (e.g. embedded
/// downstream wrappers) can wire it manually if they share the same
/// `Arc<Mutex<Vec<RevertRequest>>>` with [`AgentLoopConfig::revert_pending`](crate::agent_loop::AgentLoopConfig).
pub struct RevertTool {
    /// Shared queue read by the agent-loop drain. The Arc + Mutex pattern is
    /// identical to `PrunTool::pending`.
    pending: Arc<Mutex<Vec<RevertRequest>>>,
}

impl RevertTool {
    /// Bind a new `RevertTool` to a shared pending queue.
    pub fn new(pending: Arc<Mutex<Vec<RevertRequest>>>) -> Self {
        Self { pending }
    }
}

#[async_trait::async_trait]
impl AgentTool for RevertTool {
    fn name(&self) -> &str {
        "revert_to_state"
    }

    fn label(&self) -> &str {
        "Revert to State"
    }

    fn description(&self) -> &str {
        "Abandon the current branch and return the conversation trunk to an earlier node. Use when a branch failed (failure), an exploration is finished (tangent), a sub-task is sealed (completion), or the trunk is long enough that a checkpoint helps (step-summary). Supply a one-line `summary` distilling what to remember; it is attached as an annotation on the target node so the next turn sees the lesson without the abandoned chatter. Abandoned messages stay in the forensic session log; only the active conversation context is rebuilt."
    }

    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "category": {
                    "type": "string",
                    "enum": ["failure", "tangent", "completion", "step-summary"],
                    "description": "failure = dead-end branch to learn from; tangent = finished exploration to fold back; completion = sealed sub-task outcome; step-summary = checkpoint a long ongoing trunk."
                },
                "step": {
                    "type": "string",
                    "description": "Node identifier to revert to. Accepts the inline render (e.g. \"n10\") or a bare integer (\"10\")."
                },
                "summary": {
                    "type": "string",
                    "description": "Optional one-line summary attached as an annotation on the target node — what to remember about the abandoned branch."
                }
            },
            "required": ["category", "step"]
        })
    }

    /*
    DESIGN: execute() enqueues; apply_revert (Phase 3) mutates.

    Three responsibilities:
      1. Parse + validate `category` (must be one of the four kebab-case values).
      2. Parse + validate `step` (lenient — `NodeId::parse` accepts both `"n12"`
         and `"12"`).
      3. Optionally lift `summary` (any non-string value is treated as absent).

    On success: push a `RevertRequest` and return a synthetic ack so the LLM
    sees the call was accepted. The real work — moving the active pointer,
    attaching the NodeTag, emitting the event, rejecting unsafe targets —
    happens in `apply_revert`.

    `_ctx` is unused: there is no I/O, no cancellation budget to honour, no
    streaming output. Same shape as `PrunTool::execute`.
    */
    async fn execute(
        &self,
        params: serde_json::Value,
        _ctx: ToolContext,
    ) -> Result<ToolResult, ToolError> {
        let category_raw = params
            .get("category")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ToolError::InvalidArgs("category is required".to_string()))?;
        let category: RevertCategory = serde_json::from_value(serde_json::Value::String(
            category_raw.to_string(),
        ))
        .map_err(|_| {
            ToolError::InvalidArgs(format!(
                "category must be one of failure | tangent | completion | step-summary; got {:?}",
                category_raw
            ))
        })?;

        let step_raw = params
            .get("step")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ToolError::InvalidArgs("step is required".to_string()))?;
        let target = NodeId::parse(step_raw).ok_or_else(|| {
            ToolError::InvalidArgs(format!(
                "step must be a node identifier like \"n12\" or \"12\"; got {:?}",
                step_raw
            ))
        })?;

        let summary = params
            .get("summary")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        self.pending.lock().unwrap().push(RevertRequest {
            category,
            target,
            summary: summary.clone(),
        });

        let ack_text = match summary.as_deref() {
            Some(s) => format!(
                "Revert request recorded: category={:?}, target={}, summary={:?}. The trunk will be rebuilt from this node before the next turn.",
                category, target, s
            ),
            None => format!(
                "Revert request recorded: category={:?}, target={}. The trunk will be rebuilt from this node before the next turn.",
                category, target
            ),
        };
        Ok(ToolResult {
            content: vec![Content::Text { text: ack_text }],
            details: serde_json::Value::Null,
            child_loop_id: None,
        })
    }
}