Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/agents/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ static MITA_MANIFEST: LazyLock<AgentManifest> = LazyLock::new(|| AgentManifest {
"update-soul-state".to_string(),
"evolve-soul".to_string(),
"update-session-title".to_string(),
"update-proactive-config".to_string(),
],
max_children: Some(0),
max_context_tokens: None,
Expand Down
25 changes: 25 additions & 0 deletions crates/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ pub struct MitaConfig {
serialize_with = "humantime_serde::serialize"
)]
pub heartbeat_interval: Duration,

/// Proactive signal filter configuration. When absent, event-driven
/// proactive signals are disabled (heartbeat-only mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proactive: Option<rara_kernel::proactive::ProactiveConfig>,
}

/// Configuration for the gateway supervisor.
Expand Down Expand Up @@ -345,8 +350,28 @@ pub async fn start_with_options(
boot::McpDynamicToolProvider::new(rara.mcp_manager.clone()),
));

// Prefer Mita's own proactive.yaml (updated at runtime by the
// update-proactive-config tool) over the main config file.
let proactive_config = {
let runtime_path = rara_paths::config_dir().join("mita").join("proactive.yaml");
match std::fs::read_to_string(&runtime_path) {
Ok(contents) => match serde_yaml::from_str(&contents) {
Ok(c) => {
tracing::info!(path = %runtime_path.display(), "loaded proactive config from runtime override");
Some(c)
}
Err(e) => {
tracing::warn!(error = %e, path = %runtime_path.display(), "invalid runtime proactive config, falling back to main config");
config.mita.proactive.clone()
}
},
Err(_) => config.mita.proactive.clone(),
}
};

let kernel_config = rara_kernel::kernel::KernelConfig {
mita_heartbeat_interval: Some(config.mita.heartbeat_interval),
proactive: proactive_config,
context_folding: config.context_folding.clone(),
..Default::default()
};
Expand Down
182 changes: 182 additions & 0 deletions crates/app/src/tools/mita_update_proactive_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright 2025 Rararulab
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Mita-exclusive tool for dynamically updating the proactive filter
//! configuration (quiet hours, cooldowns, rate limits).
//!
//! Reads/writes `config_dir()/mita/proactive.yaml` so changes persist
//! across restarts without touching the main config file.

use std::{collections::HashMap, path::PathBuf, time::Duration};

use async_trait::async_trait;
use rara_kernel::{
proactive::ProactiveConfig,
tool::{ToolContext, ToolExecute},
};
use rara_tool_macro::ToolDef;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::info;

use super::notify::push_notification;

/// Valid field names that can be updated.
const UPDATABLE_FIELDS: &[&str] = &["quiet_hours", "max_hourly", "cooldowns"];

/// Input parameters for the update-proactive-config tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct UpdateProactiveConfigParams {
/// Field to update. One of: "quiet_hours", "max_hourly", "cooldowns".
field: String,
/// New value as JSON (will be parsed according to field type).
value: Value,
}

/// Mita-exclusive tool: update a specific field in the proactive filter config.
#[derive(ToolDef)]
#[tool(
name = "update-proactive-config",
description = "Update proactive filter configuration. Adjusts quiet hours, cooldowns, or rate \
limits based on user preferences.",
bypass_interceptor
)]
pub struct UpdateProactiveConfigTool;

impl UpdateProactiveConfigTool {
/// Create a new instance.
pub fn new() -> Self { Self }
}

/// Resolve the proactive config file path: `config_dir()/mita/proactive.yaml`.
fn config_path() -> PathBuf { rara_paths::config_dir().join("mita").join("proactive.yaml") }

/// Load the current proactive config from disk, returning `None` if the file
/// does not exist.
fn load_config() -> anyhow::Result<Option<ProactiveConfig>> {
let path = config_path();
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(&path)?;
let config: ProactiveConfig = serde_yaml::from_str(&contents)?;
Ok(Some(config))
}

/// Write the proactive config to disk, creating parent directories if needed.
fn save_config(config: &ProactiveConfig) -> anyhow::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let yaml = serde_yaml::to_string(config)?;
std::fs::write(&path, yaml)?;
Ok(())
}

#[async_trait]
impl ToolExecute for UpdateProactiveConfigTool {
type Output = Value;
type Params = UpdateProactiveConfigParams;

async fn run(
&self,
params: UpdateProactiveConfigParams,
context: &ToolContext,
) -> anyhow::Result<Value> {
if !UPDATABLE_FIELDS.contains(&params.field.as_str()) {
anyhow::bail!(
"invalid field '{}': must be one of {}",
params.field,
UPDATABLE_FIELDS.join(", ")
);
}

let mut config = load_config()?.ok_or_else(|| {
anyhow::anyhow!(
"proactive config not found at {}; cannot update a non-existent config",
config_path().display()
)
})?;

match params.field.as_str() {
"quiet_hours" => {
// Accept null to disable, or ["HH:MM", "HH:MM"] to set.
let quiet: Option<(String, String)> = serde_json::from_value(params.value.clone())
.map_err(|e| {
anyhow::anyhow!(
"invalid quiet_hours value: {e}. Expected null or [\"HH:MM\", \
\"HH:MM\"]"
)
})?;
config.quiet_hours = quiet;
info!(
quiet_hours = ?config.quiet_hours,
"proactive config: quiet_hours updated"
);
}
"max_hourly" => {
let max: u32 = serde_json::from_value(params.value.clone()).map_err(|e| {
anyhow::anyhow!("invalid max_hourly value: {e}. Expected a positive integer")
})?;
config.max_hourly = max;
info!(max_hourly = max, "proactive config: max_hourly updated");
}
"cooldowns" => {
// Accept a map of signal_kind -> seconds.
let raw: HashMap<String, u64> = serde_json::from_value(params.value.clone())
.map_err(|e| {
anyhow::anyhow!(
"invalid cooldowns value: {e}. Expected an object mapping signal \
names to seconds"
)
})?;
// Merge into existing cooldowns rather than replacing.
for (key, secs) in &raw {
config
.cooldowns
.insert(key.clone(), Duration::from_secs(*secs));
}
info!(
merged_keys = raw.len(),
total = config.cooldowns.len(),
"proactive config: cooldowns updated"
);
}
_ => unreachable!(),
}

save_config(&config)?;

// Notify kernel to reload the in-memory proactive filter.
let _ = context
.event_queue
.try_push(rara_kernel::event::KernelEventEnvelope::reload_proactive_config());

push_notification(
context,
format!(
"\u{2699}\u{fe0f} Proactive config updated: {}",
params.field
),
);

Ok(json!({
"status": "ok",
"field": params.field,
"message": format!("Proactive config field '{}' updated.", params.field)
}))
}
}
4 changes: 4 additions & 0 deletions crates/app/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod mita_distill_user_notes;
mod mita_evolve_soul;
mod mita_list_sessions;
mod mita_read_tape;
mod mita_update_proactive_config;
mod mita_update_session_title;
mod mita_update_soul_state;
mod mita_write_user_note;
Expand Down Expand Up @@ -69,6 +70,7 @@ use mita_distill_user_notes::DistillUserNotesTool;
use mita_evolve_soul::EvolveSoulTool;
use mita_list_sessions::ListSessionsTool;
use mita_read_tape::ReadTapeTool;
use mita_update_proactive_config::UpdateProactiveConfigTool;
use mita_update_session_title::UpdateSessionTitleTool;
use mita_update_soul_state::UpdateSoulStateTool;
use mita_write_user_note::MitaWriteUserNoteTool;
Expand Down Expand Up @@ -240,6 +242,8 @@ pub fn register_all(registry: &mut ToolRegistry, deps: ToolDeps) -> ToolRegistra
// Mita soul evolution tools
Arc::new(UpdateSoulStateTool::new()),
Arc::new(EvolveSoulTool::new()),
// Mita proactive config tool
Arc::new(UpdateProactiveConfigTool::new()),
// ACP delegation
Arc::new(AcpDelegateTool::new(deps.acp_registry.clone())),
// ACP management tools
Expand Down
48 changes: 48 additions & 0 deletions crates/kernel/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,51 @@ Detects when the agent is stuck calling the same tool repeatedly without progres
- Do NOT publish TaskReport without going through the syscall — `exec_publish_report` enforces `source_session` and `tags` invariants
- Do NOT use `UserId("system")` in synthetic messages for ProactiveTurn — always use the subscription owner's identity to prevent privilege escalation on session restore
- Do NOT construct `TaskNotification` outside `handle_publish_task_report` — it builds the `TaskReportRef` and coordinates result persistence

---

## Proactive V2 — Event-Driven Proactive Signals

### What

Event-driven proactive signals that supplement the polling heartbeat. Internal kernel events (idle sessions, task failures, time triggers) emit `ProactiveSignal` variants, which pass through a pure rule-based `ProactiveFilter`, then an optional lightweight LLM judgment layer (`signal_judgment.rs`), before being delivered to Mita as structured context packs.

### Key Files

| File | Role |
|------|------|
| `proactive/signal.rs` | `ProactiveSignal` enum (5 signal kinds) |
| `proactive/config.rs` | `ProactiveConfig` — YAML-driven filter settings |
| `proactive/filter.rs` | `ProactiveFilter` — quiet hours, cooldowns, rate limiting |
| `proactive/context.rs` | `build_context_pack()` / `build_heartbeat_context_pack()` |
| `proactive/judgment.rs` | Group-chat LLM judgment (pre-existing, unchanged) |
| `proactive/signal_judgment.rs` | Lightweight LLM pre-filter for proactive signals |
| `kernel.rs` | Signal emit points + `handle_proactive_signal` + scheduler time events |

### Signal Flow

```
Kernel event (IdleCheck / TaskFailed / Scheduler)
→ ProactiveSignal created
→ ProactiveFilter::should_pass() (quiet hours → cooldown → rate limit)
→ KernelEvent::ProactiveSignal pushed to event queue
→ handle_proactive_signal() builds context pack
→ signal_judgment (optional LLM pre-filter, lightweight model)
→ deliver_proactive_to_mita() sends to Mita session
```

`SessionCompleted` is idle-based: fires after `session_completed_secs` (~10min) of inactivity, not on turn completion.

### Critical Invariants

- `ProactiveFilter` is behind `std::sync::Mutex<Option<...>>` in the Kernel — `None` when proactive config is absent, disabling all signals
- `try_emit_proactive_signal()` is the single entry point for all signal emission — do NOT push `ProactiveSignal` events directly to the queue
- `ProactiveConfig` does NOT derive `Default` — absence means "feature off"
- Time events (MorningGreeting/DailySummary) are computed from `work_hours_start`/`work_hours_end` + timezone in the processor 0 scheduler

### What NOT To Do

- Do NOT push `KernelEventEnvelope::proactive_signal()` without going through `try_emit_proactive_signal()` — it enforces filter checks and records fire timestamps
- Do NOT derive `Default` on `ProactiveConfig` — absence means feature off, and `judgment_model` absence means no LLM pre-filter
- Do NOT add new signal kinds without adding a `kind_name()` match arm — cooldown keys depend on it
- Do NOT bypass signal judgment by calling `deliver_proactive_to_mita` directly — always go through `handle_proactive_signal` which enforces the judgment layer
36 changes: 36 additions & 0 deletions crates/kernel/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,21 @@ pub enum KernelEvent {
/// delivers a heartbeat message to it.
MitaHeartbeat,

// === Proactive ===
/// Proactive signal for Mita orchestration.
///
/// Emitted by internal event sources (idle check, task failure, time
/// triggers) after passing through the `ProactiveFilter`.
ProactiveSignal(crate::proactive::ProactiveSignal),

// === System ===
/// Reload proactive filter configuration from disk.
///
/// Emitted by the `update-proactive-config` tool after writing a new
/// config to `config_dir()/mita/proactive.yaml`. The kernel re-reads
/// the file and reconstructs the in-memory `ProactiveFilter`.
ReloadProactiveConfig,

/// Periodic idle check — transitions Ready sessions to Suspended.
IdleCheck,

Expand All @@ -389,6 +403,8 @@ impl KernelEvent {
| Self::ScheduledTask { .. }
| Self::MitaDirective { .. }
| Self::MitaHeartbeat
| Self::ProactiveSignal(_)
| Self::ReloadProactiveConfig
| Self::IdleCheck => EventPriority::Low,
}
}
Expand Down Expand Up @@ -592,6 +608,14 @@ impl KernelEventEnvelope {
}
}

/// Create a `ProactiveSignal` event.
pub fn proactive_signal(signal: crate::proactive::ProactiveSignal) -> Self {
Self {
base: EventBase::from(SessionKey::new()),
kind: KernelEvent::ProactiveSignal(signal),
}
}

/// Create a `MitaHeartbeat` event.
pub fn mita_heartbeat() -> Self {
Self {
Expand All @@ -600,6 +624,14 @@ impl KernelEventEnvelope {
}
}

/// Create a `ReloadProactiveConfig` event.
pub fn reload_proactive_config() -> Self {
Self {
base: EventBase::from(SessionKey::new()),
kind: KernelEvent::ReloadProactiveConfig,
}
}

/// Create an `IdleCheck` event.
pub fn idle_check() -> Self {
Self {
Expand Down Expand Up @@ -682,6 +714,10 @@ impl KernelEventEnvelope {
format!("mita directive for session {}", self.base.session_key)
}
KernelEvent::MitaHeartbeat => "periodic mita heartbeat".to_string(),
KernelEvent::ProactiveSignal(signal) => {
format!("proactive signal: {}", signal.kind_name())
}
KernelEvent::ReloadProactiveConfig => "reload proactive filter config".to_string(),
KernelEvent::IdleCheck => "periodic idle check".to_string(),
KernelEvent::Shutdown => "shutdown requested".to_string(),
}
Expand Down
Loading