deepstrike-core 0.2.70

Cross-language agent runtime kernel — pure computation, zero I/O
Documentation
//! Pure projection helpers from the canonical Kernel Wire contract to host-facing facts.
//!
//! This module intentionally contains no host I/O, clocks, randomness, or runtime state.  The
//! first slice centralises publication-manifest extraction; action projection is added on top of
//! the same boundary in the following TDD cards.

use serde::{Deserialize, Serialize};

use super::driver::PlannedStep;
use super::effect::{
    ArchivePageOutEffect, CallProviderEffect, EffectKind, EffectKindTag, EvaluateMilestoneEffect,
    ExecuteToolsEffect, KernelEffect, LoadPayloadEffect, MeasurePromptEffect, PersistMemoryEffect,
    PreemptTasksEffect, QueryMemoryEffect, RequestApprovalEffect, SpawnTasksEffect,
};
use super::scalar::EffectId;

/// The minimal fact a host may append to its event log for a committed step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublishedEffectRef {
    pub effect_id: EffectId,
    pub kind: EffectKindTag,
}

/// Host-facing action with one canonical effect payload.  Payload structs are the existing wire
/// structs; this first slice centralises selection without inventing a second payload schema.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CanonicalHostAction {
    CallProvider {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: CallProviderEffect,
    },
    ExecuteTools {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: ExecuteToolsEffect,
    },
    RequestApproval {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: RequestApprovalEffect,
    },
    SpawnTasks {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: SpawnTasksEffect,
    },
    PreemptTasks {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: PreemptTasksEffect,
    },
    PersistMemory {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: PersistMemoryEffect,
    },
    QueryMemory {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: QueryMemoryEffect,
    },
    ArchivePageOut {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: ArchivePageOutEffect,
    },
    LoadPayload {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: LoadPayloadEffect,
    },
    EvaluateMilestone {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: EvaluateMilestoneEffect,
    },
    MeasurePrompt {
        effect_id: EffectId,
        causation_input_id: super::scalar::InputId,
        payload: MeasurePromptEffect,
    },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "state", content = "action", rename_all = "snake_case")]
pub enum KernelProjection {
    Idle,
    Action(CanonicalHostAction),
    Terminal(super::terminal::KernelTerminal),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionError {
    pub message: String,
}

/// Project one wire effect without touching host state.
pub fn project_effect(effect: &KernelEffect) -> CanonicalHostAction {
    let effect_id = effect.effect_id.clone();
    let causation_input_id = effect.causation_input_id.clone();
    match &effect.effect {
        EffectKind::CallProvider(payload) => CanonicalHostAction::CallProvider {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::ExecuteTools(payload) => CanonicalHostAction::ExecuteTools {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::RequestApproval(payload) => CanonicalHostAction::RequestApproval {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::SpawnTasks(payload) => CanonicalHostAction::SpawnTasks {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::PreemptTasks(payload) => CanonicalHostAction::PreemptTasks {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::PersistMemory(payload) => CanonicalHostAction::PersistMemory {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::QueryMemory(payload) => CanonicalHostAction::QueryMemory {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::ArchivePageOut(payload) => CanonicalHostAction::ArchivePageOut {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::LoadPayload(payload) => CanonicalHostAction::LoadPayload {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::EvaluateMilestone(payload) => CanonicalHostAction::EvaluateMilestone {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
        EffectKind::MeasurePrompt(payload) => CanonicalHostAction::MeasurePrompt {
            effect_id,
            causation_input_id,
            payload: payload.clone(),
        },
    }
}

/// Select the current host action from one committed step.  The step's vector is already the
/// kernel's publication order, so the first effect is the only legal current action.
pub fn project_action(step: &PlannedStep) -> Result<KernelProjection, ProjectionError> {
    project_pending_action(step.disposition.terminal(), step.disposition.effects())
}

/// Return the first effect in a committed step's publication order. Transitional SDK adapters
/// use this selector while their payload conversion is migrated to [`project_effect`].
pub fn current_effect(step: &PlannedStep) -> Option<&KernelEffect> {
    step.disposition.effects().first()
}

/// JSON bridge used by bindings for transitions that carry a planned step directly.
pub fn project_planned_step_json(raw: &str) -> Result<String, ProjectionError> {
    let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
        message: format!("invalid planned step: {error}"),
    })?;
    let projection = project_action(&step)?;
    serde_json::to_string(&projection).map_err(|error| ProjectionError {
        message: format!("projection serialization failed: {error}"),
    })
}

/// JSON bridge for the host audit manifest. This intentionally excludes payloads and preserves
/// the publication order already encoded in the planned step.
pub fn published_effects_manifest_json(raw: &str) -> Result<String, ProjectionError> {
    let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
        message: format!("invalid planned step: {error}"),
    })?;
    serde_json::to_string(&published_effects_manifest(&step)).map_err(|error| ProjectionError {
        message: format!("manifest serialization failed: {error}"),
    })
}

/// Project the current action from the transaction's already ordered pending-effect view.
/// Ordering is deliberately owned by `KernelTransaction::pending_effects_in_order`; this helper
/// only selects the head and maps it to a canonical action.
pub fn project_pending_action<'a, I>(
    terminal: Option<&super::terminal::KernelTerminal>,
    effects: I,
) -> Result<KernelProjection, ProjectionError>
where
    I: IntoIterator<Item = &'a KernelEffect>,
{
    if let Some(terminal) = terminal {
        return Ok(KernelProjection::Terminal(terminal.clone()));
    }
    match effects.into_iter().next() {
        Some(effect) => Ok(KernelProjection::Action(project_effect(effect))),
        None => Ok(KernelProjection::Idle),
    }
}

/// Extract effects in the step's publication/mint order.
///
/// A terminal step publishes no effects.  The function deliberately preserves the vector order
/// from `PlannedStep`; re-sorting by map keys here would reintroduce the step:10-before-step:9
/// bug that this projection boundary exists to prevent.
pub fn published_effects_manifest(step: &PlannedStep) -> Vec<PublishedEffectRef> {
    step.disposition.effects().iter().map(effect_ref).collect()
}

fn effect_ref(effect: &KernelEffect) -> PublishedEffectRef {
    PublishedEffectRef {
        effect_id: effect.effect_id.clone(),
        kind: effect.tag(),
    }
}

#[cfg(test)]
mod tests {
    use super::{
        CanonicalHostAction, KernelProjection, project_action, project_effect,
        project_pending_action, published_effects_manifest,
    };
    use crate::runtime::kernel::wire::{EffectKindTag, PlannedStep};

    #[test]
    fn manifest_preserves_multi_effect_publication_order() {
        let fixture: serde_json::Value = serde_json::from_str(include_str!(
            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
        ))
        .expect("fixture JSON");
        let step: PlannedStep =
            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");

        let manifest = published_effects_manifest(&step);

        assert_eq!(manifest.len(), 2);
        assert_eq!(
            manifest[0].effect_id.as_str(),
            "op-contract:step:9:effect:0"
        );
        assert_eq!(manifest[0].kind, EffectKindTag::QueryMemory);
        assert_eq!(
            manifest[1].effect_id.as_str(),
            "op-contract:step:9:effect:1"
        );
        assert_eq!(manifest[1].kind, EffectKindTag::ExecuteTools);
    }

    #[test]
    fn terminal_step_has_empty_manifest() {
        let step = PlannedStep {
            root_kind: None,
            focus: None,
            observations: Vec::new(),
            disposition: crate::runtime::kernel::wire::StepDisposition::Terminal(
                crate::runtime::kernel::wire::TerminalDisposition {
                    terminal: crate::runtime::kernel::wire::KernelTerminal::Cancelled(
                        crate::runtime::kernel::wire::CancelledTerminal {
                            reason: crate::runtime::kernel::wire::CancellationReason::HostShutdown,
                            usage: crate::runtime::kernel::wire::UsageReport {
                                input_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
                                output_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
                                turns: 0,
                                cached_input_tokens: None,
                            },
                        },
                    ),
                },
            ),
        };

        assert!(published_effects_manifest(&step).is_empty());
    }

    #[test]
    fn projects_the_first_query_memory_effect_to_a_typed_action() {
        let fixture: serde_json::Value = serde_json::from_str(include_str!(
            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
        ))
        .expect("fixture JSON");
        let step: PlannedStep =
            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
        let effect = step.disposition.effects().first().expect("first effect");

        let action = project_effect(effect);

        match action {
            CanonicalHostAction::QueryMemory {
                effect_id, payload, ..
            } => {
                assert_eq!(effect_id.as_str(), "op-contract:step:9:effect:0");
                assert_eq!(payload.query.text, "past briefs");
                assert_eq!(payload.requested_k, 4);
            }
            other => panic!("expected query_memory action, got {other:?}"),
        }
    }

    #[test]
    fn canonical_action_serialization_keeps_wire_kind_and_identity() {
        let fixture: serde_json::Value = serde_json::from_str(include_str!(
            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
        ))
        .expect("fixture JSON");
        let step: PlannedStep =
            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
        let action = project_effect(step.disposition.effects().first().expect("effect"));
        let value = serde_json::to_value(action).expect("action JSON");

        assert_eq!(value["kind"], "query_memory");
        assert_eq!(value["effect_id"], "op-contract:step:9:effect:0");
        assert_eq!(value["causation_input_id"], "in-9");
        assert_eq!(value["payload"]["requested_k"], 4);
        assert_eq!(value["payload"]["query"]["text"], "past briefs");
    }

    #[test]
    fn projection_serialization_has_explicit_state_tag() {
        let fixture: serde_json::Value = serde_json::from_str(include_str!(
            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
        ))
        .expect("fixture JSON");
        let step: PlannedStep =
            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
        let value = serde_json::to_value(project_action(&step).expect("projection"))
            .expect("projection JSON");

        assert_eq!(value["state"], "action");
        assert_eq!(value["action"]["kind"], "query_memory");
        assert_eq!(value["action"]["effect_id"], "op-contract:step:9:effect:0");
    }

    #[test]
    fn planned_step_json_bridge_matches_projection() {
        let fixture: serde_json::Value = serde_json::from_str(include_str!(
            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
        ))
        .expect("fixture JSON");
        let output = super::project_planned_step_json(
            &serde_json::to_string(&fixture["planned_step"]).expect("planned step JSON"),
        )
        .expect("projection JSON");
        let value: serde_json::Value = serde_json::from_str(&output).expect("projection");
        assert_eq!(value["state"], "action");
        assert_eq!(value["action"]["kind"], "query_memory");
    }

    #[test]
    fn projection_selects_first_effect_and_distinguishes_idle() {
        let fixture: serde_json::Value = serde_json::from_str(include_str!(
            "../../../../../../tests/fixtures/abi/multi_effect_step.json"
        ))
        .expect("fixture JSON");
        let step: PlannedStep =
            serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");

        let projection = project_action(&step).expect("projection");
        assert!(matches!(
            projection,
            KernelProjection::Action(CanonicalHostAction::QueryMemory { .. })
        ));

        let idle = PlannedStep {
            root_kind: None,
            focus: None,
            observations: Vec::new(),
            disposition: crate::runtime::kernel::wire::StepDisposition::Effects(Default::default()),
        };
        assert!(matches!(
            project_action(&idle).expect("projection"),
            KernelProjection::Idle
        ));

        let ordered = step.disposition.effects().iter();
        assert!(matches!(
            project_pending_action(None, ordered).expect("projection"),
            KernelProjection::Action(CanonicalHostAction::QueryMemory { .. })
        ));
    }
}