trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//! The 13 SM method handlers — thin mappings onto existing surfaces (§1A.1).
//!
//! Why: each `sm.*` method maps onto exactly one existing SM surface; keeping the
//! handlers here (separate from the router) makes the "method → surface" table
//! the spec mandates legible at a glance, and keeps every file under the SLOC
//! cap. NO business logic lives here — each handler parses its params, calls the
//! mapped surface, and shapes the result. All errors are typed
//! ([`MethodError`]) so the router maps them to JSON-RPC codes by variant.
//! What: `sm_chat`/`sm_health` → [`SessionManagerAgent`]; `sm_goals_*` →
//! [`SmGoalStore`] (feature-gated); `sm_sessions_*` → [`SessionControl`];
//! `sm_context_get` → the SM-5 context engine (feature-gated on the goal/context
//! availability decision).
//! Test: `tests.rs` round-trips every handler through the router.

use serde_json::{Value, json};

use super::SmDispatcher;
use super::control::{LaunchParams, SessionControlError};

/// A typed handler failure the router maps to a JSON-RPC error (§1A.2).
///
/// Why: the router needs to choose the right JSON-RPC error code per failure
/// class WITHOUT string-matching, and every handler must stay panic-free. A
/// typed enum carries the class + a human message + optional structured data.
/// What: [`InvalidParams`](MethodError::InvalidParams) (bad/missing params →
/// `-32602`), [`NotFound`](MethodError::NotFound) (absent goal/session → a
/// server-defined `-32001`), [`Unavailable`](MethodError::Unavailable) (a surface
/// gated off, e.g. goals without `sm-memory` → `-32002`), and
/// [`Internal`](MethodError::Internal) (any backend failure → `-32603`).
/// Test: `tests.rs` asserts the mapped code for each path.
#[derive(Debug, thiserror::Error)]
pub enum MethodError {
    /// Missing or malformed params → JSON-RPC `INVALID_PARAMS` (-32602).
    #[error("{0}")]
    InvalidParams(String),

    /// A referenced goal/session was not found → server error -32001.
    #[error("{0}")]
    NotFound(String),

    /// A surface is unavailable in this build (feature gated) → server -32002.
    #[error("{0}")]
    Unavailable(String),

    /// Any backend failure → JSON-RPC `INTERNAL_ERROR` (-32603).
    #[error("{0}")]
    Internal(String),
}

/// Server-defined JSON-RPC error code for "referenced entity not found".
pub const CODE_NOT_FOUND: i32 = -32001;
/// Server-defined JSON-RPC error code for "surface unavailable in this build".
pub const CODE_UNAVAILABLE: i32 = -32002;

/// Validate that params is an object or absent/null (else invalid-params).
///
/// Why: `sm.*` methods accept an optional params OBJECT; an array or scalar
/// `params` is malformed and must be a clean invalid-params error rather than a
/// silent miss. Absent/null is allowed (methods with no required fields).
/// What: returns `Ok(())` for `Null`/object params; [`MethodError::InvalidParams`]
/// otherwise.
/// Test: `tests.rs` drives both present and absent params.
fn ensure_obj(params: &Value) -> Result<(), MethodError> {
    match params {
        Value::Null | Value::Object(_) => Ok(()),
        other => Err(MethodError::InvalidParams(format!(
            "params must be a JSON object, got {other}"
        ))),
    }
}

/// Extract a REQUIRED, non-blank string field from params.
///
/// Why: several methods require a string (`message`, `description`, `session_id`,
/// `text`, `id`, `workdir`); a missing/non-string/blank value must be a clean
/// invalid-params error, not a panic. Distinguishing "absent" from
/// "present-but-blank" gives a caller an actionable message instead of a
/// misleading "missing" when they DID supply the key (just whitespace).
/// What: returns the original (untrimmed) string when present and non-blank;
/// otherwise [`MethodError::InvalidParams`] — with a "missing/not a string"
/// message when the field is absent or non-string, and a distinct "must not be
/// blank" message when it is present but trims to empty. Blank strings are still
/// rejected.
/// Test: `tests.rs::*_missing_required_param_is_invalid_params`,
/// `blank_required_param_is_distinct_invalid_params`.
fn req_str(params: &Value, field: &str) -> Result<String, MethodError> {
    ensure_obj(params)?;
    match params.get(field).and_then(Value::as_str) {
        Some(s) if !s.trim().is_empty() => Ok(s.to_string()),
        Some(_) => Err(MethodError::InvalidParams(format!(
            "param `{field}` must not be blank"
        ))),
        None => Err(MethodError::InvalidParams(format!(
            "missing required string param `{field}`"
        ))),
    }
}

/// Extract an OPTIONAL string field from params (absent/null/empty → `None`).
fn opt_str(params: &Value, field: &str) -> Option<String> {
    params
        .get(field)
        .and_then(Value::as_str)
        .filter(|s| !s.trim().is_empty())
        .map(str::to_string)
}

// ── sm.chat / sm.health (SessionManagerAgent: SM-7 / §5.3) ──────────────────────

/// `sm.chat { message, conv_id? }` → `{ reply, conv_id, cost? }` (SM-7).
///
/// Why: the headline method — drives one full SM conversational turn via
/// [`SessionManagerAgent::chat`]. The adapter only parses params and shapes the
/// outcome; the §7.5 assembly + provider call live in SM-7.
/// What: parses `message` (required) + `conv_id` (optional), calls `chat`, and
/// returns `{ reply, conv_id, cost }`. A degraded SM (no provider) maps to a
/// graceful [`MethodError::Unavailable`]; any other chat failure →
/// [`MethodError::Internal`].
/// Test: `tests.rs::chat_round_trips`, `chat_degraded_is_unavailable`.
pub async fn sm_chat(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let message = req_str(params, "message")?;
    let conv_id = opt_str(params, "conv_id");
    match d.agent.chat(&message, conv_id.as_deref()).await {
        Ok(outcome) => Ok(json!({
            "reply": outcome.reply,
            "conv_id": outcome.conv_id,
            "cost": outcome.cost_usd,
        })),
        Err(crate::core::sm::SmAgentError::Degraded(notice)) => {
            Err(MethodError::Unavailable(notice))
        }
        Err(e) => Err(MethodError::Internal(e.to_string())),
    }
}

/// `sm.delegate { message }` → the delegation-loop outcome (SM-8, §3.4).
///
/// Why: the capstone method — drives the FULL 6-phase delegation loop
/// ([`SessionManagerAgent::delegate_goal`]) over the dispatcher's session-control
/// and goal-store surfaces. This is the §1A.2 step-1 flow `claude-mpm ⟷ SM ⟷ t-mpm`:
/// a driver sends a goal, the SM creates a tracked goal, launches and delivers
/// session(s), observes, verifies with evidence (the §3.5 gate), and reports.
/// Unlike `sm.chat` (a conversational turn), this runs the launch/observe/verify
/// mechanism end-to-end.
/// What: under `sm-memory`, parses `message` (required), calls `delegate_goal`
/// with the dispatcher's `sessions` + `goals`, and returns
/// `{ reply, goal_id, launched, goal_done, goal_status }`. `goal_status` carries the
/// goal's actual lifecycle label (`Pending`/`InProgress`/`Blocked`/`Done`/
/// `Abandoned`) so a caller can distinguish in-progress from blocked/failed WITHOUT
/// a follow-up `sm.goals.list`; `goal_done` is kept additively for back-compat. A
/// degraded SM (no provider) maps to [`MethodError::Unavailable`]; any other failure
/// → [`MethodError::Internal`]. The goal store is feature-independent, so this runs
/// in EVERY build (#1477) — goals are non-durable in-memory on the default build.
/// Test: `tests.rs::delegate_end_to_end_launch_observe_verify_close`,
/// `delegate_gate_blocks_without_evidence`.
pub async fn sm_delegate(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let message = req_str(params, "message")?;
    match d.agent.delegate_goal(&message, &d.sessions, &d.goals).await {
        Ok(outcome) => Ok(json!({
            "reply": outcome.reply,
            "goal_id": outcome.goal_id,
            "launched": outcome.launched,
            "goal_done": outcome.goal_done,
            "goal_status": outcome.goal_status,
        })),
        Err(crate::core::sm::agent::DelegationError::Degraded(notice)) => {
            Err(MethodError::Unavailable(notice))
        }
        Err(e) => Err(MethodError::Internal(e.to_string())),
    }
}

/// `sm.health {}` → `{ ok, provider, degraded, model_tiers }` (§5.3).
///
/// Why: a parent driver probes SM readiness before driving turns. Maps directly
/// onto [`SessionManagerAgent::health`] (no network call).
/// What: calls `health` and serializes the [`SmHealth`](crate::core::sm::SmHealth).
/// Test: `tests.rs::health_round_trips`.
pub async fn sm_health(d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
    let health = d.agent.health().await;
    serde_json::to_value(health).map_err(|e| MethodError::Internal(e.to_string()))
}

// ── sm.sessions.* (SessionControl: §2.6) ────────────────────────────────────────

/// `sm.sessions.launch { workdir, model?, prompt?, goal_id? }` → `{ session_id }`.
///
/// Why: maps onto the managed-session spawn surface (§2.6) via [`SessionControl`].
/// When a `goal_id` is supplied the launched session is LINKED to that goal (§9.3)
/// through the goal store — the one place the adapter touches two surfaces, and
/// only to record the spec-mandated link, not to add logic.
/// What: builds [`LaunchParams`] from params, calls `sessions.launch`, then (when
/// `goal_id` is present and the goal store is available) links the new
/// `session_id` to the goal. Returns `{ session_id }` (link failures are reported
/// as a `linked: false` note, never failing an otherwise-successful launch).
/// Test: `tests.rs::launch_round_trips`, `launch_with_goal_links_session`.
pub async fn sm_sessions_launch(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let workdir = req_str(params, "workdir")?;
    let goal_id = opt_str(params, "goal_id");
    let launch = LaunchParams {
        workdir,
        model: opt_str(params, "model"),
        prompt: opt_str(params, "prompt"),
        goal_id: goal_id.clone(),
        // #1508: honour an explicit `ephemeral` flag from the wire so a
        // chat/test launch can mark its session disposable; absent → durable.
        ephemeral: params.get("ephemeral").and_then(Value::as_bool),
        // WI-A #1585: honour optional repo_url / ref_ from the wire params so
        // the SM-STDIO dispatch path threads them through to the spawn.
        repo_url: opt_str(params, "repo_url"),
        ref_: opt_str(params, "ref_"),
    };
    let result = d.sessions.launch(launch).await.map_err(map_control_err)?;

    // §9.3: record the goal↔session link when requested. Best-effort: a link
    // failure must not undo a successful launch.
    if let Some(goal_id) = goal_id {
        let session_id = result
            .get("session_id")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
        let linked = link_session_to_goal(d, &goal_id, &session_id).await;
        if !linked {
            // Best-effort link (§9.3): an unknown goal or a persist failure must
            // NOT undo a successful launch, but it must be observable rather than
            // silently swallowed — `linked: false` rides back in the response too.
            tracing::warn!(
                session_id = %session_id,
                goal_id = %goal_id,
                "sm.sessions.launch: goal↔session link failed (best-effort); session launched but not linked"
            );
        }
        let mut out = result;
        if let Some(obj) = out.as_object_mut() {
            obj.insert("goal_id".to_string(), json!(goal_id));
            obj.insert("linked".to_string(), json!(linked));
        }
        return Ok(out);
    }
    Ok(result)
}

/// `sm.sessions.list {}` → `{ sessions: [...] }` (§2.6).
pub async fn sm_sessions_list(d: &SmDispatcher, _params: &Value) -> Result<Value, MethodError> {
    d.sessions.list().await.map_err(map_control_err)
}

/// `sm.sessions.get { session_id }` → `{ session, ... }` (§2.6).
pub async fn sm_sessions_get(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let session_id = req_str(params, "session_id")?;
    d.sessions.get(&session_id).await.map_err(map_control_err)
}

/// `sm.sessions.send { session_id, text }` → `{ ok }` (§2.6).
pub async fn sm_sessions_send(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let session_id = req_str(params, "session_id")?;
    let text = req_str(params, "text")?;
    d.sessions
        .send(&session_id, &text)
        .await
        .map_err(map_control_err)
}

/// `sm.sessions.stop { session_id }` → `{ ok, runtime_left_running }` (§2.6;
/// the second field is #8935's, additive).
pub async fn sm_sessions_stop(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let session_id = req_str(params, "session_id")?;
    d.sessions.stop(&session_id).await.map_err(map_control_err)
}

/// `sm.sessions.resume { session_id }` → `{ ok }` (§2.6).
pub async fn sm_sessions_resume(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let session_id = req_str(params, "session_id")?;
    d.sessions
        .resume(&session_id)
        .await
        .map_err(map_control_err)
}

/// `sm.sessions.kill { session_id }` → `{ ok }` (§2.6 force-stop/reap).
pub async fn sm_sessions_kill(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let session_id = req_str(params, "session_id")?;
    d.sessions.kill(&session_id).await.map_err(map_control_err)
}

/// Map a [`SessionControlError`] onto the handler error class.
///
/// Why: the session control surface distinguishes not-found from backend
/// failures; preserving that distinction lets the router pick the right JSON-RPC
/// code.
/// What: `NotFound` → [`MethodError::NotFound`]; `Backend` → [`MethodError::Internal`].
/// Test: `tests.rs::get_unknown_session_is_not_found`.
fn map_control_err(e: SessionControlError) -> MethodError {
    match e {
        SessionControlError::NotFound(msg) => MethodError::NotFound(msg),
        SessionControlError::Backend(msg) => MethodError::Internal(msg),
    }
}

// ── sm.context.get (SM-5 context engine — always available) ─────────────────────

/// `sm.context.get { conv_id }` → context state (§7.1/§7.5).
///
/// Why: surfaces the rolling-context engine's state — the compressed block, the
/// recent verbatim rounds, the total round count, and the token estimate — so a
/// driver can inspect what context the SM is carrying for a conversation. The
/// context engine is compiled in EVERY build (it depends only on serde + the SM
/// provider trait, not on memory-core), so this method is available regardless of
/// the `sm-memory` feature (#1477) — it never needed the SM palace.
/// What: opens the per-`conv_id` engine under the SM data root and returns
/// `{ compressed_context, recent_rounds, total_rounds, token_estimate }`. A missing
/// `conv_id` is an invalid-params error; a store failure is an internal error.
/// Test: `tests.rs::context_get_feature_branches`.
pub async fn sm_context_get(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    use crate::core::sm::SmContextEngine;

    let conv_id = req_str(params, "conv_id")?;
    let engine = SmContextEngine::open(
        &conv_id,
        &d.data_root,
        &d.config.inference,
        &d.config.rounds,
    )
    .map_err(|e| MethodError::Internal(e.to_string()))?;
    let conv = engine.conversation();
    let recent: Vec<Value> = conv
        .recent_rounds
        .iter()
        .map(|r| json!({ "user": r.user, "assistant": r.assistant }))
        .collect();
    Ok(json!({
        "compressed_context": conv.compressed_context,
        "recent_rounds": recent,
        "total_rounds": conv.total_rounds,
        "token_estimate": conv.token_estimate,
    }))
}

// ── sm.goals.* (SmGoalStore — always available, #1477) ──────────────────────────

/// `sm.goals.list { status? }` → `{ goals: [Goal] }` (SM-6).
///
/// Why: lists tracked operator goals, optionally filtered by status. Maps onto the
/// SM-6 goal store, which is feature-independent (#1477): palace-backed durable
/// storage under `sm-memory`, non-durable in-memory on the default build.
/// What: reads `store.all()` and (when `status` is supplied) filters by it; returns
/// `{ goals }`.
/// Test: `tests.rs::goals_feature_branches`.
pub async fn sm_goals_list(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let status = opt_str(params, "status");
    let store = d.goals.lock().await;
    let goals: Vec<_> = store
        .all()
        .into_iter()
        .filter(|g| match &status {
            Some(s) => goal_status_str(g) == s.to_ascii_lowercase(),
            None => true,
        })
        .collect();
    serde_json::to_value(json!({ "goals": goals }))
        .map_err(|e| MethodError::Internal(e.to_string()))
}

/// `sm.goals.create { description, acceptance? }` → `{ goal: Goal }` (SM-6).
pub async fn sm_goals_create(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let description = req_str(params, "description")?;
    let acceptance = params
        .get("acceptance")
        .and_then(Value::as_array)
        .map(|a| {
            a.iter()
                .filter_map(Value::as_str)
                .map(str::to_string)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let mut store = d.goals.lock().await;
    let goal = store
        .create(description, acceptance)
        .await
        .map_err(|e| MethodError::Internal(e.to_string()))?;
    serde_json::to_value(json!({ "goal": goal })).map_err(|e| MethodError::Internal(e.to_string()))
}

/// `sm.goals.update { id, status?, progress?, note? }` → `{ goal: Goal }` (SM-6).
///
/// Why: maps onto the SM-6 goal update path. `note` appends a free-form note;
/// `status` transitions the goal (the verification gate applies to `done`).
/// What: under `sm-memory`, applies a `note` (if present) then a `status` change
/// (if present), returning the final goal. A `done` request that fails the
/// verification gate maps to [`MethodError::Internal`] (the gate message). The
/// spec's `progress` field is DERIVED by the store from session states (§9.1) and
/// is not directly settable, so it is accepted-but-ignored with a note.
/// Test: `tests.rs::goals_update_round_trips`.
pub async fn sm_goals_update(d: &SmDispatcher, params: &Value) -> Result<Value, MethodError> {
    let id = req_str(params, "id")?;
    let note = opt_str(params, "note");
    let status = opt_str(params, "status");
    let mut store = d.goals.lock().await;

    if let Some(note) = note {
        store.note(&id, note).await.map_err(map_goal_err)?;
    }
    if let Some(status) = status {
        let parsed = parse_goal_status(&status)?;
        store.set_status(&id, parsed).await.map_err(map_goal_err)?;
    }
    let goal = store
        .get(&id)
        .cloned()
        .ok_or_else(|| MethodError::NotFound(format!("goal not found: {id}")))?;
    serde_json::to_value(json!({ "goal": goal })).map_err(|e| MethodError::Internal(e.to_string()))
}

/// The lowercase wire status string for a goal (matches the camelCase serde).
fn goal_status_str(goal: &crate::core::sm::Goal) -> String {
    use crate::core::sm::GoalStatus;
    match goal.status {
        GoalStatus::Pending => "pending",
        GoalStatus::InProgress => "inprogress",
        GoalStatus::Blocked => "blocked",
        GoalStatus::Done => "done",
        GoalStatus::Abandoned => "abandoned",
    }
    .to_string()
}

/// Parse a wire status string into a [`GoalStatus`].
///
/// Why: `sm.goals.update` accepts a status string; an unknown value must be a
/// clean invalid-params error, not a panic.
/// What: case-insensitively matches the five statuses (accepting both
/// `inprogress` and `in_progress`).
/// Test: `tests.rs::goals_update_round_trips`.
fn parse_goal_status(s: &str) -> Result<crate::core::sm::GoalStatus, MethodError> {
    use crate::core::sm::GoalStatus;
    match s
        .trim()
        .to_ascii_lowercase()
        .replace(['_', '-'], "")
        .as_str()
    {
        "pending" => Ok(GoalStatus::Pending),
        "inprogress" => Ok(GoalStatus::InProgress),
        "blocked" => Ok(GoalStatus::Blocked),
        "done" => Ok(GoalStatus::Done),
        "abandoned" => Ok(GoalStatus::Abandoned),
        other => Err(MethodError::InvalidParams(format!(
            "unknown goal status {other:?}; expected one of: \
             pending, inProgress, blocked, done, abandoned"
        ))),
    }
}

/// Map an [`SmGoalError`](crate::core::sm::SmGoalError) onto a handler error.
///
/// Why: the goal store distinguishes not-found from other failures; preserving it
/// lets the router pick the right JSON-RPC code.
/// What: `NotFound` → [`MethodError::NotFound`]; everything else (palace/cache/
/// gate) → [`MethodError::Internal`].
/// Test: `tests.rs::goals_update_unknown_is_not_found`.
fn map_goal_err(e: crate::core::sm::SmGoalError) -> MethodError {
    match e {
        crate::core::sm::SmGoalError::NotFound(msg) => {
            MethodError::NotFound(format!("not found: {msg}"))
        }
        other => MethodError::Internal(other.to_string()),
    }
}

/// Link a launched session to a goal (best-effort, §9.3).
///
/// Why: `sm.sessions.launch` with a `goal_id` must record the goal↔session link;
/// doing it here keeps the launch handler's two-surface touch minimal. A link
/// failure (unknown goal / persist error) is reported, never fatal to the launch.
/// What: calls `store.link`; returns whether it succeeded.
/// Test: `tests.rs::launch_with_goal_links_session`.
async fn link_session_to_goal(d: &SmDispatcher, goal_id: &str, session_id: &str) -> bool {
    use crate::core::sm::SessionLink;
    let mut store = d.goals.lock().await;
    store
        .link(
            goal_id,
            SessionLink::launched(session_id, "session-manager launched task"),
        )
        .await
        .is_ok()
}