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
//! The session underneath the run, and everything read or set through it.
//!
//! Split from [`prepared`](super) for the parent's size, along the seam that
//! was already there: every method here is a proxy onto mentra's `Session` —
//! handing it out whole, reading its transcript, or changing what the next
//! turn will run on. Nothing here opens a turn, so nothing here has to agree
//! with anything about how one is announced; the entry points next door are
//! what compose these into a run.
use mentra::{Session, agent::AgentEvent};
use super::{
AgentEventTapGuard, Effort, ModelInfo, PreparedRun, ReasoningOptions, Role, RunContext,
RunError, history_text,
};
impl PreparedRun {
/// The session this run drives, for a host that wants mentra's own surface
/// — branching, the transcript tree, subagents — alongside basis's.
pub fn session(&self) -> &Session {
&self.session
}
/// Registers a lossless in-process observer for this run's agent events.
///
/// The callback receives Mentra's complete [`AgentEvent`] values unchanged,
/// synchronously and in occurrence order, before they enter the bounded
/// broadcast stream. In particular, tool calls and results retain their
/// complete provider-neutral input, structured content, and error payloads.
/// A cancellation ends the sequence with [`AgentEvent::RunFailed`].
///
/// The callback executes inline on the operation emitting the event. It
/// must return promptly and must not block or panic: blocking stalls that
/// operation, and a panic propagates through it. It must not re-enter an
/// event-emitting operation or drop a tap guard from inside the callback.
///
/// Keep the returned [`AgentEventTapGuard`] alive for as long as observation
/// is required. Dropping it waits for any invocation already in flight and
/// then unregisters; do not drop it while holding a resource that callback
/// needs. Registration does not replay earlier events.
pub fn register_agent_event_tap(
&self,
tap: impl Fn(&AgentEvent) + Send + Sync + 'static,
) -> AgentEventTapGuard {
AgentEventTapGuard::new(self.session.register_agent_event_tap(tap))
}
/// Mutably exposes Mentra's session.
pub fn session_mut(&mut self) -> &mut Session {
&mut self.session
}
/// Replaces the tool authorizer for *this session only*, over the one its
/// runtime installed.
///
/// The runtime's authorizer is basis's own
/// [`ApprovalGate`](crate::ApprovalGate), which answers nothing: every
/// consequential call comes back as a `Prompt`, and a `Prompt` is resolved
/// by a remembered rule before the run's [`Approver`](crate::Approver) is
/// ever consulted. That order is mentra's and it is deliberate — but it
/// means a policy the *approver* enforces can be pre-empted by a durable
/// rule someone seeded through
/// [`session()`](Self::session)`.permission_handle()`. An authorizer's own
/// `Allow` and `Deny` are terminal instead: mentra returns them unchanged,
/// consulting no rule and emitting no permission request. A host with a
/// posture that must not be answerable by a remembered rule puts it here
/// rather than in its approver.
///
/// Scoped to this session's agent and the descendants it spawns after this
/// call. Sibling sessions keep the runtime's. **Live-only**: mentra does
/// not persist the attachment, so a conversation picked back up with
/// [`Workspace::resume`](crate::Workspace::resume) arrives without it and
/// must be given it again — the reason `basis-acp` installs its mode gate
/// in `AcpSession::new`, which both `session/new` and `session/load` go
/// through.
///
/// Install **before the first turn**, which is what taking the run by
/// value enforces at the type level: a run already lent to a turn cannot
/// be moved. A subagent template taken from the session earlier, and any
/// child already spawned, keep the authorizer they inherited.
///
/// The replacement is total, not layered — mentra has no way to hand back
/// the runtime authorizer being displaced, so an implementation here owns
/// the whole question, `ApprovalGate`'s
/// [`is_consequential`](crate::approval::is_consequential) filter included.
/// One that answered `Prompt` for a read would put every read to whoever
/// is approving.
pub fn with_tool_authorizer<A>(self, authorizer: A) -> Self
where
A: crate::approval::ToolAuthorizer + 'static,
{
// Destructured rather than updated in place: mentra's `Session` is
// consumed by its own `with_tool_authorizer`, so there is no borrow of
// this run that could hold the rest of it together across the move.
let Self {
session,
run,
bounds,
workspace,
agent_row,
retry_policy,
context_snapshot,
} = self;
Self {
session: session.with_tool_authorizer(authorizer),
run,
bounds,
workspace,
agent_row,
retry_policy,
context_snapshot,
}
}
/// The session's id, which changes every time a session is created —
/// including on resume.
pub fn session_id(&self) -> String {
self.session.id().to_string()
}
/// The persisted agent id: the handle
/// [`resume`](crate::Workspace::resume) takes.
///
/// Unlike the session id this survives the process, because it names the
/// row in mentra's store rather than this run of it.
pub fn agent_id(&self) -> &str {
self.session.agent_id()
}
/// The committed conversation so far, oldest first.
pub fn history(&self) -> &[mentra::Message] {
self.session.history()
}
/// This run's committed conversation as runtime roles plus assembled text.
///
/// Narrower than [`history`](Self::history): only the two chat roles a host
/// commonly needs, and only the assembled text each one said. Unknown roles
/// stay in `history()` for callers that want the full transcript.
pub fn text_history(&self) -> impl DoubleEndedIterator<Item = (Role, String)> + '_ {
self.session.history().iter().filter_map(history_text)
}
/// How many of the assistant's turns this run's history has committed.
///
/// The count, not the presence: a session resumed with `--continue` or
/// `--session` arrives with answers already on it, and "has this run
/// answered yet" is only a question a count can settle against a
/// watermark taken earlier — one taken right after mint, before anything
/// was asked, tells a caller recovering from a crash mid-turn whether the
/// last committed message is the crashed turn's own answer or one it
/// inherited.
///
/// The fact [`history`](Self::history) alone does not expose: reading it
/// off `history()` directly means matching on `mentra::Role`, which pulls
/// a host into a dependency on mentra's own type for a question basis can
/// just answer. This is the narrower of the two fixes — it settles
/// exactly that one count rather than growing `history()`'s element type
/// a role of basis's own, which a caller wanting the *text* of a message
/// still would not need.
pub fn answered_turns(&self) -> usize {
self.session
.history()
.iter()
.filter(|message| matches!(message.role, Role::Assistant))
.count()
}
/// The newest assistant text this run's history has committed, if any.
///
/// The text [`answered_turns`](Self::answered_turns) only counts. A host
/// recovering from a crash mid-turn asks two questions in sequence — did
/// the recorded prompt already get answered, and *what was the answer* —
/// and both are questions about `mentra::Role` that basis can settle
/// without pulling the caller into matching on it (ADR-0003: the
/// in-process Rust consumer is what the API is judged by; `basis-tasks`
/// carried a whole `mentra` dependency for this one match before this
/// existed).
///
/// `None` when nothing has answered yet, which a fresh run and a resumed
/// conversation with no assistant turn both are. Owned rather than
/// borrowed because the text is assembled from the message's parts, not
/// stored as one string.
pub fn last_assistant_text(&self) -> Option<String> {
self.session
.history()
.iter()
.rev()
.find(|message| matches!(message.role, Role::Assistant))
.map(|message| message.text())
}
/// This run's model's context window, when it is known.
///
/// Read from the live session, so it is whatever mentra is compacting
/// against right now. Known when the model was resolved through the
/// provider's listing and that listing reports one — mentra looks a
/// pinned id up there too (`bfe952b`), so `--model`, a repository's
/// `config.json` and `WorkspaceBuilder::with_model` all get a window when the
/// provider publishes one. Gemini's listing does, as `inputTokenLimit`;
/// Anthropic's and the OpenAI wires' do not, and neither does a server
/// that cannot list. `None` for a run whose lossy
/// [`set_model`](Self::set_model) wrapper has since moved onto a model
/// named by id alone; the complete
/// [`set_resolved_model`](Self::set_resolved_model) preserves a supplied
/// window. Also `None` for a resumed
/// conversation that is no longer on the model its workspace resolved —
/// mentra does not persist a window, and `Workspace::resume` reapplies
/// the workspace's model only while the conversation is still on it.
pub fn context_window(&self) -> Option<usize> {
self.session.context_window()
}
/// Estimates how many tokens the next request would spend on this run's
/// history and system prompt, using mentra's own estimator
/// ([`mentra::memory::estimated_request_tokens`]) — the same one mentra's
/// auto-compaction threshold is compared against.
///
/// **A floor, not the real number.** Mentra may add a task-reminder banner
/// and a skill-description block on top of the system prompt Basis
/// configured — additions that are Mentra-private, so that gap is not
/// basis's to close. A resumed run is estimated against the prompt the
/// *persisted* agent carries, read back off the resumed session, rather
/// than the workspace's current default: the two differ whenever the
/// original run carried a profile override. A run built through
/// [`prepare_with_session`](crate::run::prepare_with_session) knows no prompt at
/// all — there is no workspace to ask — and its floor excludes one
/// entirely. Useful beside
/// [`context_window`](Self::context_window) for a host deciding whether to
/// compact or warn before mentra's own trigger would.
pub fn estimated_context_tokens(&self) -> usize {
self.context_snapshot
.estimated_tokens(self.session.history())
}
/// What this run is about, minus the session.
pub fn context(&self) -> &RunContext {
&self.run
}
/// Switches the complete resolved model this conversation's later turns
/// run on, keeping the provider it was opened with.
///
/// Takes effect from the next turn: mentra threads the model into each
/// model request as it builds it, so a turn already in flight finishes on
/// the model it started with. It also persists — mentra rewrites the agent
/// record — so a session resumed in another process comes back on the model
/// it was last set to.
///
/// The [`ModelInfo`] is handed to mentra unchanged, including its context
/// window. It is not checked against a catalogue: listing would be provider
/// activity at switch time, and host-resolved metadata is already the
/// caller's contract. An id the provider rejects fails on the next turn,
/// where that provider can say why. Mentra currently retains the id,
/// provider, and context window but neither exposes nor persists the
/// display name, description, or creation time, so those display fields
/// cannot be read back through this API.
///
/// The provider is *not* switchable here. Its identity must equal this
/// run's provider exactly; a mismatch is refused before mentra, catalogue,
/// model-request, or tool activity. A run built on one provider's
/// credential and endpoint (ADR-0018) has no second connection to move to.
///
/// This is a between-turn API. A [`RoundStrategy`](crate::RoundStrategy)
/// may make its own in-turn model adjustment, but that does not pass
/// through this method and does not rewrite basis's header/report context.
///
/// Mentra's setter mutates its live agent before persisting the record. If
/// that write fails, the returned error can therefore leave mentra's live
/// session changed while basis's [`RunContext`] still names the old model.
/// A host that needs failure-atomic phase switches should use volatile
/// history; the Nous Gate 1a attached lifecycle does so.
pub fn set_resolved_model(&mut self, model: ModelInfo) -> Result<(), RunError> {
if model.provider.as_str() != self.run.provider {
return Err(RunError::ResolvedModelProviderMismatch {
model: model.id.clone(),
model_provider: model.provider.as_str().to_string(),
runtime_provider: self.run.provider.clone(),
});
}
let model_id = model.id.clone();
self.session.set_model(model)?;
// The context is what `header()` and every report read the model from.
// Leaving it stale would have the stream describe a run that is no
// longer happening. This assignment deliberately follows the session
// call, so a rejected switch cannot rewrite the public header/report.
self.run.model = model_id;
Ok(())
}
/// Switches later turns to `model` by id, keeping the current provider.
///
/// Compatibility wrapper over [`set_resolved_model`](Self::set_resolved_model).
/// It is deliberately lossy: an id carries no context window or display
/// metadata, so [`context_window`](Self::context_window) becomes `None`.
/// Hosts that already resolved a [`ModelInfo`] should use the complete API.
pub fn set_model(&mut self, model: impl Into<String>) -> Result<(), RunError> {
let provider = self.run.provider.clone();
self.set_resolved_model(ModelInfo::new(model, provider))
}
/// Renames this conversation, and persists the new name.
///
/// The name is what [`store::list`](crate::store::list) — and so ACP's
/// `session/list` — reports as a conversation's title, and mentra fixes it
/// at creation otherwise. That is the wrong moment for it: a session is
/// opened before anyone knows what it will be about, so a host that mints
/// one per conversation is stuck offering a list of identical placeholders.
///
/// Nothing derives a name here. What a conversation should be called is a
/// convention of whatever is driving it — its first prompt, a ticket id,
/// what the user typed — and basis has no opinion to impose (PROPOSAL.md
/// Bet 4).
pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), RunError> {
self.session.set_name(name)?;
Ok(())
}
/// The name this conversation is filed under.
pub fn name(&self) -> &str {
self.session.name()
}
/// Sets complete provider-neutral reasoning options from the next turn on.
///
/// `None` clears the request and restores the provider's own default. The
/// options are forwarded whole without rewriting any other
/// [`ProviderRequestOptions`](crate::ProviderRequestOptions) field. They
/// are provider-neutral: adapters that support summaries map
/// [`ReasoningOptions::summary`] to their wire, while adapters without a
/// summary control ignore that field.
///
/// Persisted and deferred exactly as
/// [`set_resolved_model`](Self::set_resolved_model) is. Mentra likewise
/// mutates the live reasoning posture before its persistence write, so an
/// error can leave that live value changed; the Nous Gate 1a lifecycle
/// avoids that persistence edge with volatile history.
pub fn set_reasoning(&mut self, reasoning: Option<ReasoningOptions>) -> Result<(), RunError> {
self.session.set_reasoning(reasoning)?;
Ok(())
}
/// The complete reasoning options this session's next turn will receive.
pub fn reasoning(&self) -> Option<&ReasoningOptions> {
self.session.reasoning()
}
/// Asks the model for one effort level from the next turn on.
///
/// Compatibility wrapper over [`set_reasoning`](Self::set_reasoning).
/// It is deliberately lossy: changing effort through this method clears
/// any configured reasoning summary. A caller preserving complete options
/// should use [`set_reasoning`](Self::set_reasoning).
pub fn set_effort(&mut self, effort: Option<Effort>) -> Result<(), RunError> {
self.set_reasoning(effort.map(|effort| ReasoningOptions {
effort: Some(effort.into()),
summary: None,
}))
}
/// The level this session's next turn will be sent with.
///
/// Read off the session rather than tracked here, which is what makes it
/// an answer about the conversation rather than about this handle on it: a
/// run whose [`RunSpec`](crate::RunSpec) or whose repository's
/// `config.json` named an effort had it applied at mint, before anything
/// called [`set_effort`](Self::set_effort), and a tracked copy reported
/// `None` for a session demonstrably running at `high`.
///
/// `None` means no level is being requested — the provider's own default —
/// and not that nobody has asked yet. A level mentra has grown and basis
/// has no name for also reads as `None`, because reporting the wrong one
/// is worse than reporting none; see [`Effort`]'s `TryFrom`.
pub fn effort(&self) -> Option<Effort> {
self.reasoning()
.and_then(|reasoning| reasoning.effort)
.and_then(|effort| Effort::try_from(effort).ok())
}
}