daat-locus 0.1.0

A long-running local agent runtime with memory, workflows, apps, and sleep-time self-improvement.
use std::time::Duration;

use crate::{
    app::AppId,
    apply_patch::summarize_apply_patch_error,
    context::{
        ActiveWorkflowRunSession, AppNoticeKey, Context, PendingWorkflowRunFlush, RuntimeTurnPhase,
    },
    context_budget::{
        approx_token_count, estimate_agent_turn_request, is_context_budget_exceeded,
        truncate_text_to_token_budget,
    },
    dashboard::render::{
        AUTO_SLEEP_IDLE_THRESHOLD, AUTO_SLEEP_MIN_INTERVAL, FORCE_SLEEP_ERROR_BACKLOG_THRESHOLD,
        render_dashboard_footer_context, sync_dashboard_state,
    },
    dashboard::{
        DashboardActivityEvent, DashboardControlCommand, DashboardState,
        activity_cell_from_tool_ui_event, apply_activity_event, assistant_activity_cell,
    },
    events::{EventPayload, EventStatus, EventView},
    hindsight::HindsightRecallOptions,
    logging::{
        RuntimeStatusLevel, clear_runtime_status, set_runtime_status,
        write_current_turn_messages_dump, write_current_turn_response_dump,
        write_current_turn_response_error_dump,
    },
    memory::RuntimeTurnDraft,
    pending_work::PendingWork,
    preturn_state::PreTurnState,
    reasoning::{
        episode::EpisodeActionRecord,
        prompt_parts::AfterClaimContextInput,
        runtime::{
            AgentContent, AgentContentPart, AgentMessage, AgentTurnItem, AgentTurnRequest,
            AgentTurnStreamResult, HistoryMessage, PromptMemoryCitation, PromptMemoryContext,
        },
        runtime_error::{
            RuntimeErrorActionContext, RuntimeErrorCase, RuntimeErrorCaseParts, RuntimeErrorKind,
            RuntimeErrorObservation, RuntimeErrorRuntimeContext, RuntimeErrorTaskContext,
            append_runtime_error_case,
        },
        sleep::run_sleep,
    },
    runtime_context::{
        MID_TURN_COMPACTION_MAX_RECOVERIES, build_afterclaim_context_text,
        build_preturn_context_text, build_runtime_request_envelope,
        execute_pre_turn_runtime_compaction, maybe_compact_runtime_messages,
        runtime_request_budget_limits,
    },
    runtime_tools::{
        ToolExecutionResult, build_runtime_tool_specs, execute_agent_tool_call,
        render_telegram_tool_result_status, render_tool_call_ui_event,
        summarize_action_from_tool_call,
    },
    sleep_status::{
        SleepStatusSnapshot, persist_sleep_status_snapshot, refresh_sleep_status_queues,
    },
    telegram_transport::TelegramLiveDraftClient,
    tool_ui::{ToolCallUiEvent, ToolUiEvent, compact_body_lines},
    workflow::{WorkflowRunRecord, append_workflow_run_records},
    workspace_app::{WorkspaceAppInvalidation, WorkspaceAppRegistry},
};
use chrono::Utc;
use miette::{Result, miette};
use serde_json::json;
use tokio::{sync::mpsc, task::JoinHandle, time::MissedTickBehavior};

use crate::runtime::bootstrap::{
    build_eval_context_with_compiled, load_compiled_prompts_only, summarize_sleep_summary,
};
mod claimed_input;
mod dashboard_control;
mod hindsight_recall;
mod live_draft;
mod model_driver;
mod scheduler;
mod sleep_driver;
mod turn;
mod workflow_evidence;
mod workspace_apps;

pub(crate) use dashboard_control::handle_dashboard_control_command;
pub(crate) use scheduler::daat_locus_loop;
pub(crate) use sleep_driver::{SleepTaskResult, handle_sleep_task_result};
pub(crate) use turn::execute_agent_loop_step;
pub(crate) use workflow_evidence::{AgentLoopStepExecution, AgentLoopStepOutput};

use claimed_input::*;
use hindsight_recall::build_hindsight_memory_context;
use live_draft::{TelegramLiveDraftSession, maybe_start_telegram_live_draft_session};
use workflow_evidence::{record_runtime_history_messages, record_workflow_run_evidence};
use workspace_apps::{drain_workspace_app_invalidations, sync_workspace_apps_from_invalidation};

const RUNTIME_EVENT_CLAIM_BATCH_SIZE: usize = 1;
const RUNTIME_OVERFLOW_FUSE_THRESHOLD: usize = 3;
const APP_NOTICE_UNRESOLVED_SUPPRESSION_THRESHOLD: usize = 3;
const APP_NOTICE_OVERFLOW_SUPPRESSION: Duration = Duration::from_secs(300);
const RUNTIME_HISTORY_MIN_MESSAGES: usize = 0;
const RUNTIME_HISTORY_SUMMARY_MAX_TOKENS: usize = 800;
const HINDSIGHT_RECALL_QUERY_MAX_TOKENS: usize = 420;
const RUNTIME_PREFLIGHT_STAGE_TIMEOUT_SECS: u64 = 60;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn claimed_terminal_status_depends_only_on_statuses() {
        assert!(claimed_event_statuses_are_terminal(&[
            EventStatus::AwaitingDelivery
        ]));
        assert!(claimed_event_statuses_are_terminal(&[
            EventStatus::Resolved
        ]));
        assert!(claimed_event_statuses_are_terminal(&[
            EventStatus::Dismissed
        ]));
        assert!(claimed_event_statuses_are_terminal(&[EventStatus::Failed]));
        assert!(!claimed_event_statuses_are_terminal(&[
            EventStatus::Claimed
        ]));
        assert!(claimed_event_statuses_are_terminal(&[
            EventStatus::AwaitingDelivery,
            EventStatus::Resolved,
        ]));
        assert!(claimed_event_statuses_are_terminal(&[
            EventStatus::Resolved,
            EventStatus::Dismissed,
        ]));
        assert!(!claimed_event_statuses_are_terminal(&[
            EventStatus::AwaitingDelivery,
            EventStatus::Claimed,
        ]));
        assert!(!claimed_event_statuses_are_terminal(&[]));
    }

    #[test]
    fn claimed_status_summary_tracks_claimed_and_terminal_reason() {
        assert_eq!(
            summarize_claimed_event_statuses(&[EventStatus::Claimed]),
            ClaimedEventStatusSummary {
                has_claimed: true,
                all_terminal: false,
            }
        );
        assert_eq!(
            summarize_claimed_event_statuses(&[
                EventStatus::AwaitingDelivery,
                EventStatus::Resolved,
            ]),
            ClaimedEventStatusSummary {
                has_claimed: false,
                all_terminal: true,
            }
        );
        assert_eq!(
            summarize_claimed_event_statuses(&[EventStatus::Resolved, EventStatus::Failed,]),
            ClaimedEventStatusSummary {
                has_claimed: false,
                all_terminal: true,
            }
        );
        assert_eq!(
            summarize_claimed_event_statuses(&[EventStatus::Resolved, EventStatus::Claimed,]),
            ClaimedEventStatusSummary {
                has_claimed: true,
                all_terminal: false,
            }
        );
    }

    #[test]
    fn runtime_turn_follow_up_decision_state_machine_prefers_runtime_gate() {
        let state = RuntimeTurnFollowUpState {
            raw_stream_requested_follow_up: true,
            claimed_statuses: &[],
            has_claimed_app_notice: false,
            claimed_app_notice_resolved: false,
        };
        assert!(matches!(
            runtime_turn_follow_up_decision_from_state(&state),
            RuntimeFollowUpDecision::Continue { .. }
        ));

        let state = RuntimeTurnFollowUpState {
            raw_stream_requested_follow_up: false,
            claimed_statuses: &[EventStatus::Claimed],
            has_claimed_app_notice: false,
            claimed_app_notice_resolved: false,
        };
        assert!(matches!(
            runtime_turn_follow_up_decision_from_state(&state),
            RuntimeFollowUpDecision::Continue { .. }
        ));

        let state = RuntimeTurnFollowUpState {
            raw_stream_requested_follow_up: false,
            claimed_statuses: &[EventStatus::Resolved],
            has_claimed_app_notice: false,
            claimed_app_notice_resolved: false,
        };
        assert!(matches!(
            runtime_turn_follow_up_decision_from_state(&state),
            RuntimeFollowUpDecision::AllowFinish
        ));

        let state = RuntimeTurnFollowUpState {
            raw_stream_requested_follow_up: false,
            claimed_statuses: &[EventStatus::Resolved],
            has_claimed_app_notice: true,
            claimed_app_notice_resolved: false,
        };
        assert!(matches!(
            runtime_turn_follow_up_decision_from_state(&state),
            RuntimeFollowUpDecision::Continue { .. }
        ));

        let state = RuntimeTurnFollowUpState {
            raw_stream_requested_follow_up: false,
            claimed_statuses: &[EventStatus::Resolved],
            has_claimed_app_notice: true,
            claimed_app_notice_resolved: true,
        };
        assert!(matches!(
            runtime_turn_follow_up_decision_from_state(&state),
            RuntimeFollowUpDecision::AllowFinish
        ));
    }

    #[test]
    fn claimed_runtime_input_fingerprint_is_stable_and_sorted() {
        let event_a = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
        let event_b = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
        let inputs = vec![
            ClaimedRuntimeInput::AppNotice {
                app: AppId::terminal(),
                reason: "busy".to_string(),
            },
            ClaimedRuntimeInput::Event(EventView {
                event_id: event_a,
                source: crate::events::EventSource::Telegram,
                status: EventStatus::Pending,
                arrived_at_ms: 0,
                payload: EventPayload::TelegramIncoming(crate::events::TelegramIncomingEvent {
                    chat_id: "1".to_string(),
                    chat_kind: "private".to_string(),
                    chat_title: "chat".to_string(),
                    sender: "alice".to_string(),
                    incoming_text: "hello".to_string(),
                    telegram_update_id: 1,
                    telegram_message_id: None,
                    telegram_message_date: None,
                    attachments: Vec::new(),
                }),
                last_error: None,
            }),
            ClaimedRuntimeInput::Event(EventView {
                event_id: event_b,
                source: crate::events::EventSource::Telegram,
                status: EventStatus::Pending,
                arrived_at_ms: 0,
                payload: EventPayload::TelegramIncoming(crate::events::TelegramIncomingEvent {
                    chat_id: "2".to_string(),
                    chat_kind: "private".to_string(),
                    chat_title: "chat".to_string(),
                    sender: "bob".to_string(),
                    incoming_text: "world".to_string(),
                    telegram_update_id: 2,
                    telegram_message_id: None,
                    telegram_message_date: None,
                    attachments: Vec::new(),
                }),
                last_error: None,
            }),
        ];

        assert_eq!(
            claimed_runtime_input_fingerprint(&inputs).as_deref(),
            Some(
                "events=[00000000-0000-0000-0000-000000000001,00000000-0000-0000-0000-000000000002]|app_notices=[Terminal:busy]"
            )
        );
    }

    #[test]
    fn claimed_runtime_input_fingerprint_is_none_for_empty_batch() {
        assert_eq!(claimed_runtime_input_fingerprint(&[]), None);
    }

    #[test]
    fn follow_up_reason_messages_are_structured() {
        assert_eq!(
            RuntimeFollowUpReason::RawStreamRequestedFollowUp.message(),
            "This sample is still marked needs_follow_up; continue the current turn."
        );
        assert!(
            RuntimeFollowUpReason::ClaimedEventNeedsExplicitResolution
                .message()
                .contains("finish_and_send")
        );
        assert!(
            RuntimeFollowUpReason::ClaimedAppNoticeNeedsExplicitResolution
                .message()
                .contains("notice_resolved")
        );
    }

    #[test]
    fn overflow_failure_note_includes_attempt_count_and_error() {
        assert_eq!(
            runtime_overflow_failure_note(3, "context limit exceeded"),
            "runtime context overflow persisted after 3 attempts: context limit exceeded"
        );
    }
}