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
//! The chat loop's in-conversation propose→confirm plumbing (WI-9, #2586).
//!
//! Why: DOC-36 §6 phase 2 requires the CHAT LOOP itself (not just `/manager/act`)
//! to "propose a session launch/inject action in-conversation and only execute it
//! after explicit user confirmation" — a conversational realization of DOC-35 §11
//! that reuses the SAME [`super::act::ProposedAction`]/actuator seam `/manager/act`
//! uses, never a parallel implementation. This module owns the three pure/small
//! pieces that make that possible: (1) [`extract_proposed_action`] — parses an
//! LLM reply for an embedded action proposal (a fenced `manager-action` JSON
//! block, never real tool-calling — the request still carries no `tools`, so the
//! phase-1 "no tool-calling surface" invariant holds even though the chat loop is
//! no longer read-only); (2) [`is_confirmation`] — the deterministic, documented
//! confirmation-phrase policy; (3) [`ProposalStore`] — the conversation-keyed
//! pending-proposal store with a NEXT-TURN-ONLY expiry policy (chosen over a
//! wall-clock TTL because chat turns are the natural unit of "the same
//! conversation", and a turn-based expiry is exactly testable without a clock).
//! What: [`ProposalStore::set`]/[`ProposalStore::take`] (unconditional
//! consume-on-read — every turn either executes the pending proposal or drops
//! it, so a proposal NEVER survives past the very next turn on its key);
//! [`is_confirmation`]; [`extract_proposed_action`] plus the sentinel constants
//! [`ACTION_FENCE_OPEN`]/[`ACTION_FENCE_CLOSE`] the chat system prompt documents
//! to the model.
//! Test: `is_confirmation_*`, `extract_proposed_action_*`,
//! `proposal_store_take_is_consume_on_read` in `proposal_tests.rs`.
use HashMap;
use Mutex;
use ProposedAction;
/// Opening fence the chat system prompt instructs the model to use when
/// proposing an action.
///
/// Why: a fixed, greppable sentinel lets [`extract_proposed_action`] find a
/// proposal in plain LLM reply text WITHOUT wiring an actual tool-calling
/// `tools` array onto the request — the phase-1 "no tools" invariant
/// (`tests/manager_inference.rs::chat_is_read_only_no_mutation_and_no_tools`)
/// keeps holding even though the chat loop can now act.
/// What: the fenced-code-block language tag marking a proposal.
/// Test: `extract_proposed_action_parses_launch_block`.
pub const ACTION_FENCE_OPEN: &str = "```manager-action";
/// Closing fence for the proposal block (a bare triple-backtick).
pub const ACTION_FENCE_CLOSE: &str = "```";
/// Parse an LLM reply for an embedded action proposal, stripping it from the
/// visible text.
///
/// Why: the chat system prompt (`chat.rs::build_chat_messages`) instructs the
/// model to end its reply with a `manager-action` fenced JSON block — shaped
/// exactly like [`ProposedAction`]'s serde tag — ONLY when the user explicitly
/// asked for a launch/inject/summarize. This is the single parse point so the
/// handler never hand-rolls JSON extraction inline.
/// What: finds [`ACTION_FENCE_OPEN`]; if absent, returns `(reply, None)`
/// unchanged. If present but the fenced body fails to parse as a
/// [`ProposedAction`], ALSO returns `(reply, None)` unchanged — a malformed
/// proposal is treated as "no proposal" rather than a hard error, since the
/// chat loop must never fail a turn over the model's formatting slip. On a
/// successful parse, returns the reply with the fenced block (and its
/// surrounding whitespace) removed, plus `Some(action)`.
/// Test: `extract_proposed_action_parses_launch_block`,
/// `extract_proposed_action_no_block_returns_none`,
/// `extract_proposed_action_malformed_json_returns_none`,
/// `extract_proposed_action_keeps_leading_and_trailing_prose`.
/// Whether a chat message is an explicit confirmation of a pending proposal.
///
/// Why: DOC-35 §11 requires an EXPLICIT confirmation — this must be a narrow,
/// deterministic, documented predicate (never fuzzy NLU) so the confirm path is
/// hermetically testable and never accidentally triggered by an unrelated
/// message that happens to contain the word "confirm" mid-sentence.
/// What: trims the message, strips one trailing `.`/`!`/`?`, lowercases, and
/// matches the WHOLE remaining string against a fixed confirmation-phrase set:
/// `"confirm"`, `"confirmed"`, `"yes"`, `"y"`. Any other content — including a
/// message that merely mentions "confirm" — is NOT a confirmation.
/// Test: `is_confirmation_accepts_documented_phrases`,
/// `is_confirmation_rejects_partial_or_unrelated_text`.
/// Conversation-keyed store of pending action proposals, NEXT-TURN-ONLY TTL.
///
/// Why: DOC-35 §11 forbids a proposal from being executable indefinitely — an
/// operator who proposed an action three turns ago and moved on to an unrelated
/// topic must not have a stray later "confirm" retroactively fire it. Modelling
/// the expiry as "valid for exactly the next turn on this key" (rather than a
/// wall-clock TTL) ties the policy to the natural unit of a conversation and
/// needs no clock/timer machinery to test: [`Self::take`] unconditionally
/// REMOVES the entry on every read, so the caller (chat.rs) either executes it
/// (this turn's message was a confirmation) or discards it (any other message) —
/// either way it cannot survive to a THIRD turn.
/// What: a `Mutex`-guarded `HashMap<String, ProposedAction>`, keyed identically
/// to [`super::chat_store::ChatStore`] and the L2 proxy focus map.
/// Test: `proposal_store_take_is_consume_on_read`,
/// `proposal_store_distinct_keys_are_isolated`.