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
//! `POST /api/v1/manager/chat` — conversation-keyed portfolio chat loop, with a
//! phase-2 in-conversation propose→confirm action flow (WI-4 #2581, WI-9 #2586).
//!
//! Why: DOC-36 §3.2 gives `tm manager` a conversation-keyed chat turn against the
//! portfolio-manager persona. Phase 1 shipped it strictly read-only; DOC-36 §6
//! phase 2 EXPLICITLY SUPERSEDES that framing (coordinator review of #2586: the
//! issue's primary acceptance criterion is that the CHAT LOOP itself — not only
//! the standalone `/manager/act` endpoint — "proposes a session launch/inject
//! action in-conversation and only executes it after explicit user confirmation
//! in the same \[conversation\]"). This module now supports exactly that: when the
//! model's reply embeds a proposal (see [`super::proposal::extract_proposed_action`]),
//! the chat handler stores it as PENDING for that `conversation_key` and returns
//! it to the caller as advisory text — NOTHING is executed. Only when the VERY
//! NEXT turn on that same key is an explicit confirmation
//! ([`super::proposal::is_confirmation`]) does the handler execute it, via the
//! SAME [`super::act::execute_action`] dispatch and [`super::actuator::ManagerActuator`]
//! seam `/manager/act` uses — never a duplicated execution path. A pending
//! proposal NEVER survives past that immediately-following turn (next-turn-only
//! TTL, [`super::proposal::ProposalStore`]) — an operator who moves on to a
//! different topic and later says "confirm" out of context executes nothing.
//! DOC-35 §11's boundary is satisfied by the EXPLICIT CONFIRMATION TURN, not by
//! chat being structurally incapable of acting: the request still carries NO
//! `tools` (the proposal is a parsed text sentinel, never real tool-calling), and
//! a PLAIN message (no proposal, no confirmation) still never mutates anything.
//! What: [`ChatReplyBody`]/[`ChatRequestBody`], the snapshot+history prompt
//! builder [`build_chat_messages`] (now also documenting the proposal sentinel
//! format to the model), and the [`manager_chat_route`] handler, which checks the
//! pending-proposal store BEFORE any LLM call so a confirm turn executes with
//! ZERO inference calls.
//! Test: `build_chat_messages_includes_context_and_history` in `chat_tests.rs`;
//! HTTP multi-turn / degrade / no-tools coverage in `tests/manager_inference.rs`;
//! the propose→confirm suite (proposal executes nothing, confirm executes
//! exactly once, cross-conversation isolation, next-turn expiry, plain messages
//! never execute) in `tests/manager_routing.rs`.

use std::sync::Arc;

use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use serde_json::json;
use trusty_common::inference::{ChatMessage, ChatRequest};

use super::act::{ActResponse, execute_action, propose_message};
use super::actuator::resolve_actuator;
use super::chat_store::{ChatTurn, TurnRole};
use super::inference::{MANAGER_MAX_TOKENS, MANAGER_TEMPERATURE};
use super::proposal::{extract_proposed_action, is_confirmation};
use super::status::{PortfolioStatusResponse, load_portfolio_status};
use crate::daemon::error::DaemonError;
use crate::daemon::state::DaemonState;

/// Request body for `POST /api/v1/manager/chat`.
///
/// Why: DOC-36 §3.2 fixes the shape `{ conversation_key, message }`; the
/// conversation key mirrors the L2 proxy focus-map keying so the surface is
/// channel-agnostic. The SAME key drives the propose→confirm action flow
/// (#2586): a `message` on this key that [`is_confirmation`] returns `true` for
/// executes whatever proposal is pending for it.
/// What: the caller-supplied conversation key and the new user message.
/// Test: HTTP coverage in `tests/manager_inference.rs`, `tests/manager_routing.rs`.
#[derive(Debug, Deserialize)]
pub struct ChatRequestBody {
    /// Conversation key (same keying shape as `SessionProxy`'s focus map).
    pub conversation_key: String,
    /// The new user message for this turn.
    pub message: String,
}

/// Success reply body for `POST /api/v1/manager/chat`.
///
/// Why: returns the assistant's reply plus enough metadata (model, turn count) for
/// a channel/CLI client to attribute and thread the conversation, and — new in
/// phase 2 — the structured [`ActResponse`] when this turn proposed OR executed
/// an action, so a client does not have to scrape the prose for it.
/// What: the echoed conversation key, the reply prose, the authoring model slug,
/// the retained turn count, and `action_result` (absent on an ordinary turn;
/// `ActResponse::Proposed` when this turn's reply embedded a new proposal; the
/// matching executed variant — `Launched`/`Injected`/`Summarized`/etc. — when
/// this turn confirmed a pending one).
/// Test: HTTP coverage in `tests/manager_routing.rs`.
#[derive(Debug, Serialize)]
pub struct ChatReplyBody {
    /// The conversation this reply belongs to.
    pub conversation_key: String,
    /// The manager's reply prose.
    pub reply: String,
    /// The model slug that authored the reply (or, on a zero-inference confirm
    /// turn, the currently-configured model slug — reported for consistency,
    /// even though no call was made this turn).
    pub model: String,
    /// Retained message count for this conversation after recording the exchange.
    pub turn_count: usize,
    /// The proposed or executed action for this turn, if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub action_result: Option<ActResponse>,
}

/// Build the chat prompt: persona + live snapshot + recent turns.
///
/// Why: the reply must be grounded in the CURRENT deterministic portfolio state
/// (rebuilt fresh each request) and coherent across turns (recent history). The
/// persona is advisory by default — it must not CLAIM to have already acted — but
/// phase 2 (#2586) documents the `manager-action` sentinel so the model MAY
/// propose (never silently execute) exactly one action when the user explicitly
/// asks for a launch/inject/summarize. This is a parsed text convention, not real
/// tool-calling: the request still carries no `tools`, so the "no tool-calling
/// surface" invariant `tests/manager_inference.rs` pins still holds.
/// What: a system message (persona + proposal-format instructions + the
/// pretty-printed snapshot), then the prior `history` replayed as user/assistant
/// messages, then the new `user_message`.
/// Test: `build_chat_messages_includes_context_and_history`.
pub fn build_chat_messages(
    status: &PortfolioStatusResponse,
    history: &[ChatTurn],
    user_message: &str,
) -> Vec<ChatMessage> {
    let snapshot = serde_json::to_string_pretty(status)
        .unwrap_or_else(|_| "{\"error\":\"snapshot serialization failed\"}".to_string());
    let system = format!(
        "You are the portfolio manager for a software developer running many coding \
         sessions across multiple projects. Answer questions about the portfolio using \
         ONLY the deterministic status snapshot below and the conversation so far — never \
         invent projects, sessions, or counts. You are advisory: you must NOT claim to have \
         already launched, injected, killed, resumed, or changed anything — you may only \
         PROPOSE an action, which a human confirms separately before it runs.\n\n\
         If — and ONLY if — the user explicitly asks you to launch a session for a \
         project, send/inject a message into a specific session, or summarize a specific \
         session, you MAY end your reply with exactly one fenced proposal block in this \
         exact form (a bare JSON object, no extra keys):\n\
         ```manager-action\n\
         {{\"type\":\"launch\",\"project\":\"<project name>\",\"task\":\"<task text>\"}}\n\
         ```\n\
         or\n\
         ```manager-action\n\
         {{\"type\":\"inject\",\"session\":\"<session id or name>\",\"text\":\"<message text>\"}}\n\
         ```\n\
         or\n\
         ```manager-action\n\
         {{\"type\":\"summarize\",\"session\":\"<session id or name>\"}}\n\
         ```\n\
         Never include a proposal block unless the user asked for one of these three \
         actions. Never propose more than one action in a single reply. The proposal is \
         NOT executed automatically — a human must reply on this same conversation with an \
         explicit confirmation before anything runs.\n\n\
         Current portfolio snapshot (JSON):\n{snapshot}"
    );
    let mut messages = Vec::with_capacity(history.len() + 2);
    messages.push(ChatMessage::system(system));
    for turn in history {
        match turn.role {
            TurnRole::User => messages.push(ChatMessage::user(turn.content.clone())),
            TurnRole::Assistant => messages.push(ChatMessage::assistant(turn.content.clone())),
        }
    }
    messages.push(ChatMessage::user(user_message.to_string()));
    messages
}

/// Render a typed chat degrade response (`error` + actionable `message`).
///
/// Why: unlike the digest, a chat reply has no deterministic templated
/// substitute — when inference is unavailable or fails there is nothing to
/// synthesize, so the surface returns a typed error the caller can act on.
/// What: a `(status, Json{ error, message })` response.
/// Test: HTTP coverage in `tests/manager_inference.rs`.
fn chat_error(status: StatusCode, error: &str, message: String) -> axum::response::Response {
    (status, Json(json!({ "error": error, "message": message }))).into_response()
}

/// Describe an executed [`ActResponse`] as reply prose.
///
/// Why: the confirm turn skips the LLM entirely (deterministic, zero-inference
/// execution — DOC-16 D1's "one LLM call per operation" is respected by making
/// this turn ZERO calls rather than a redundant narration call), so the visible
/// `reply` text is rendered here instead of by the model.
/// What: one line per outcome variant.
/// Test: exercised via the confirm-turn HTTP coverage in `tests/manager_routing.rs`.
fn describe_action_result(result: &ActResponse) -> String {
    match result {
        ActResponse::Proposed { proposal, .. } => proposal.clone(),
        ActResponse::Launched {
            session_id, name, ..
        } => format!("Launched session '{name}' ({session_id})."),
        ActResponse::Injected { name, text, .. } => {
            format!("Sent to session '{name}': {text:?}")
        }
        ActResponse::Summarized { name, summary, .. } => {
            format!("Session '{name}': {summary}")
        }
        ActResponse::SessionNotFound { session, error } => {
            format!("Could not find session '{session}': {error}")
        }
        ActResponse::SessionVanished { session, error } => {
            format!("Session '{session}' is gone: {error}")
        }
        ActResponse::ActionFailed { session, error } => {
            format!("Action on session '{session}' failed: {error}")
        }
    }
}

/// `POST /api/v1/manager/chat` handler — conversation-keyed, with the phase-2
/// in-conversation propose→confirm action flow.
///
/// Why: the curl-first (§4) portfolio chat surface. It threads a conversation by
/// key, grounds each reply in the live deterministic snapshot plus recent turns,
/// and — per #2586 — lets the model propose (never silently execute) a session
/// action, executing it ONLY on an explicit confirming turn on the SAME key.
/// What: validates the body (400 on empty key/message); consults
/// [`super::state::ManagerState::proposals`] FIRST — if this key has a pending
/// proposal, it is unconditionally consumed (next-turn-only TTL): a confirming
/// message ([`is_confirmation`]) executes it via [`execute_action`] with ZERO
/// LLM calls; any other message discards it and falls through to the normal
/// flow. The normal flow loads the deterministic snapshot, resolves inference
/// (503 on no provider), issues ONE completion with NO `tools` attached, parses
/// the reply for an embedded proposal ([`extract_proposed_action`]) — storing it
/// as pending and stripping it from the visible text — and records the exchange
/// (in-memory window + best-effort palace dual-write). Never logs message/reply
/// text (privacy).
/// Test: HTTP coverage in `tests/manager_inference.rs`, `tests/manager_routing.rs`.
pub async fn manager_chat_route(
    State(state): State<Arc<DaemonState>>,
    Json(body): Json<ChatRequestBody>,
) -> impl IntoResponse {
    // #6288: the body is shared with `mpm.manager.chat`.
    match chat_op(&state, body).await {
        Ok(reply) => Json(reply).into_response(),
        Err(e) => render_chat_error(e),
    }
}

/// Render a chat failure in this route's historical body shape (#6288).
///
/// Why: three of the four failure classes have always answered a typed
/// `{ error, message }` JSON object, and the fourth — a store read failure
/// reaching here from [`load_portfolio_status`] — has always answered plain
/// text. Both wires are preserved exactly; only where the failure is DECIDED
/// moved, from the handler into [`chat_op`].
fn render_chat_error(e: DaemonError) -> axum::response::Response {
    match &e {
        DaemonError::InvalidRequest(m) => {
            chat_error(StatusCode::BAD_REQUEST, "invalid_request", m.clone())
        }
        DaemonError::ServiceUnavailable(m) => chat_error(
            StatusCode::SERVICE_UNAVAILABLE,
            "inference_unavailable",
            m.clone(),
        ),
        DaemonError::UpstreamFailed(m) => {
            chat_error(StatusCode::BAD_GATEWAY, "inference_failed", m.clone())
        }
        other => (other.status(), other.detail()).into_response(),
    }
}

/// [`manager_chat_route`]'s body, with no transport in it (#6288 slice 5).
///
/// Why nothing about the propose→confirm flow moved: the pending-proposal store
/// is consulted, and unconditionally consumed, before any inference call — that
/// ordering is what makes the confirm turn deterministic and zero-LLM, and it is
/// preserved verbatim here. A socket caller therefore gets the same next-turn
/// TTL semantics as an HTTP one.
///
/// # Errors
///
/// [`DaemonError::InvalidRequest`] on a blank conversation key or message;
/// [`DaemonError::Internal`] when a store read fails;
/// [`DaemonError::ServiceUnavailable`] when no provider is configured;
/// [`DaemonError::UpstreamFailed`] when the provider call fails or returns
/// nothing.
///
/// Test: `parity_manager_chat_agrees_across_transports`,
/// `rpc_manager_chat_rejects_a_blank_conversation_key`.
pub async fn chat_op(
    state: &Arc<DaemonState>,
    body: ChatRequestBody,
) -> Result<ChatReplyBody, DaemonError> {
    let conversation_key = body.conversation_key.trim().to_string();
    if conversation_key.is_empty() {
        return Err(DaemonError::InvalidRequest(
            "conversation_key must not be empty".to_string(),
        ));
    }
    if body.message.trim().is_empty() {
        return Err(DaemonError::InvalidRequest(
            "message must not be empty".to_string(),
        ));
    }

    let manager = state.manager_state();

    // Phase 2 (#2586): consult the pending-proposal store BEFORE any LLM call.
    // `take` unconditionally consumes (next-turn-only TTL) — a confirming
    // message executes it via the SAME actuator seam `/manager/act` uses; any
    // other message just lets it expire and falls through to the normal flow
    // (the `take` above already consumed it either way).
    if let Some(pending) = manager.proposals().take(&conversation_key)
        && is_confirmation(&body.message)
    {
        let actuator = resolve_actuator(state);
        let action_result = match execute_action(&actuator, &conversation_key, pending).await {
            Ok(response) => response,
            Err(error) => {
                tracing::warn!("manager chat: confirmed action failed: {error}");
                ActResponse::ActionFailed {
                    session: "(launch)".to_string(),
                    error,
                }
            }
        };
        let reply = describe_action_result(&action_result);
        let model = manager.inference().model();
        let turn_count =
            manager
                .conversations()
                .record_exchange(&conversation_key, &body.message, &reply);
        manager
            .palace()
            .record_chat_turn(&conversation_key, &body.message, &reply)
            .await;
        return Ok(ChatReplyBody {
            conversation_key,
            reply,
            model,
            turn_count,
            action_result: Some(action_result),
        });
    }

    let status = load_portfolio_status(state).await?;

    let (model, adapter) = manager
        .inference()
        .resolve()
        .map_err(|unavailable| DaemonError::ServiceUnavailable(unavailable.to_string()))?;

    let history = manager.conversations().history(&conversation_key);
    // No tool-calling surface: NO `tools` are attached, so the model has no API
    // surface to call a mutating verb directly — any action it wants is only ever
    // a PARSED TEXT PROPOSAL (see `extract_proposed_action`), never executed by
    // this call.
    let mut request = ChatRequest::new(
        model.clone(),
        build_chat_messages(&status, &history, &body.message),
    );
    request.max_tokens = Some(MANAGER_MAX_TOKENS);
    request.temperature = Some(MANAGER_TEMPERATURE);

    let raw_reply = match adapter.chat(&request).await {
        Ok(response) => match response.first_text().filter(|t| !t.trim().is_empty()) {
            Some(text) => text,
            None => {
                return Err(DaemonError::UpstreamFailed(
                    "provider returned an empty reply".to_string(),
                ));
            }
        },
        Err(e) => {
            tracing::warn!("manager chat inference call failed: {e}");
            return Err(DaemonError::UpstreamFailed(
                "inference call failed".to_string(),
            ));
        }
    };

    // Parse for an embedded proposal; on a hit, store it as pending (next turn
    // on this key may confirm it) and show the human-readable proposal text
    // instead of the raw fenced block.
    let (visible_text, proposed) = extract_proposed_action(&raw_reply);
    let (reply, action_result) = match proposed {
        Some(action) => {
            manager.proposals().set(&conversation_key, action.clone());
            let proposal = propose_message(&action);
            let reply = if visible_text.is_empty() {
                proposal.clone()
            } else {
                format!("{visible_text}\n\n{proposal}")
            };
            (reply, Some(ActResponse::Proposed { proposal, action }))
        }
        None => (raw_reply, None),
    };

    // Record the completed exchange: in-memory window (always) + portfolio palace
    // dual-write (best-effort; silent no-op when the palace is unavailable).
    let turn_count =
        manager
            .conversations()
            .record_exchange(&conversation_key, &body.message, &reply);
    manager
        .palace()
        .record_chat_turn(&conversation_key, &body.message, &reply)
        .await;

    Ok(ChatReplyBody {
        conversation_key,
        reply,
        model,
        turn_count,
        action_result,
    })
}

#[cfg(test)]
#[path = "chat_tests.rs"]
mod tests;