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
//! The turn whose answer is a value rather than prose.
//!
//! Split from [`prepared`](super) for the parent's size, and along the seam
//! that was already there: everything here is ADR-0010's structured output —
//! one terminal tool, one deserialization, and the error distinction basis
//! draws that mentra does not. The untyped turn next door shares
//! [`begin`](super::PreparedRun::begin) and
//! [`finish`](super::PreparedRun::finish) with it, which is what keeps the two
//! announcing themselves identically on the stream.
use serde::de::DeserializeOwned;
use serde_json::Value;
use super::{
Approver, Ended, EventSink, OutputAttempt, OutputAttemptReport, OutputDecision, OutputReport,
OutputReservation, OutputSpec, PreparedRun, PromptPart, RunError, TurnOptions, bounded,
drawable, prompt,
};
impl PreparedRun {
/// Drive one multipart working or shaping turn whose candidate output is
/// validated before its generated tool may terminate the run.
///
/// Once the turn begins, every in-turn ending returns an
/// [`OutputAttemptReport`], including a decode mismatch, a missing output,
/// a tripped bound, or a runtime failure. Only setup and stream-forwarding
/// failures remain outer [`RunError`]s.
pub async fn output_parts_validated_with_options<T, S, A, V>(
&mut self,
parts: Vec<PromptPart>,
reservation: OutputReservation,
validator: V,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<OutputAttemptReport<T, S>, RunError>
where
T: DeserializeOwned,
S: EventSink,
A: Approver,
V: Fn(&Value) -> OutputDecision + Send + Sync + 'static,
{
let options = bounded(options, &self.bounds);
drawable(&options)?;
let turn = self.begin(&parts, sink, approver)?;
let (usage, usage_tap) = self.observe_usage();
let run_options = self.run_options(options);
let observed = run_options.clone();
let result = self
.session
.append_turn_to_reserved_output::<Value, _>(
prompt::into_blocks(parts),
run_options,
reservation.into_inner(),
move |candidate| match validator(candidate) {
OutputDecision::Accept(value) => mentra::TerminalOutputDecision::Accept(value),
OutputDecision::Reject(reason) => {
mentra::TerminalOutputDecision::Reject(reason)
}
},
)
.await;
let usage = Self::finish_usage(usage, usage_tap);
let (output, failure) = match result {
Ok(output) => match serde_json::from_value::<T>(output.value) {
Ok(value) => (OutputAttempt::Accepted(value), None),
Err(error) => (OutputAttempt::Mismatch(error), None),
},
Err(error) => (OutputAttempt::Missing, Some(error)),
};
let ended = match (&output, &failure) {
(OutputAttempt::Accepted(_), _) => Ended::Answered(None),
(OutputAttempt::Mismatch(error), _) => Ended::Mismatched(error),
(OutputAttempt::Missing, Some(error)) => Ended::Failed(error),
(OutputAttempt::Missing, None) => unreachable!("a missing value carries its failure"),
};
let mut report = self.finish(turn, ended, &observed, usage).await?;
report.failure = failure;
Ok(OutputAttemptReport { output, report })
}
/// Sends a prompt whose answer must be a value of type `T` rather than
/// prose.
///
/// ADR-0010's structured output, and the primitive a workflow is built on:
/// the model is handed one terminal tool whose input *is* the answer, and
/// `T` is deserialized from what it sent. The caller writes the schema —
/// see [`OutputSpec`] for why basis derives nothing.
///
/// **By default a typed turn is a shaping turn, not a working one.** That
/// terminal tool is the *only* tool the turn holds — no files, no shell, no
/// MCP — and the model is required to call it, so the turn can answer only
/// from the conversation it already has. Asking it to review code in the
/// same call returns a structurally valid answer from a model that read
/// nothing, reported as a success. Two ways past that, and they are
/// different trades. [`OutputSpec::with_tools`] keeps the ordinary toolset
/// on this turn, so one call reads and answers — and gives up the forcing
/// that guaranteed an answer. Or do the work on an ordinary turn
/// ([`send`](Self::send) or [`execute`](Self::execute)) and ask for the
/// shape on the next, which keeps the forcing and keeps each run's reading
/// in a context of its own; `examples/review_workflow.rs` is that written
/// out.
///
/// The stream is unchanged. Header, forwarded events, permissions put to
/// the approver, `RunFinished`: a client reading events cannot tell a typed
/// turn from any other, which is the point — only the return value differs.
/// The answer travels as the terminal tool's
/// [`ToolQueued`](crate::Event::ToolQueued) input and
/// [`ToolCompleted`](crate::Event::ToolCompleted) summary, and
/// [`RunReport::final_message`](crate::RunReport::final_message) stays
/// `None`, because a typed turn's
/// committed final message is that tool result — putting a JSON payload in
/// a field named for the assistant's prose would have every client render
/// it as speech. Prose the model wrote alongside the call, usually none,
/// arrives as [`Event::AssistantMessage`](crate::Event::AssistantMessage).
///
/// Where a plain turn reports its failure on the stream and still returns
/// `Ok`, this returns `Err`: a typed turn without a value has nothing to
/// hand back.
///
/// - [`RunError::OutputMismatch`] — an answer arrived that `T` did not
/// accept. mentra commits the exchange before basis reads it, so the
/// transcript keeps the attempt and a follow-up turn can say what was
/// wrong with it.
/// - [`RunError::Runtime`] — the turn failed, *or* it finished without ever
/// calling the terminal tool. mentra reports both as
/// `MalformedProviderEvent` and basis will not read error prose to tell
/// them apart. A working turn ([`OutputSpec::with_tools`]) reaches the
/// second of those the most ways, since nothing forces its ending: it can
/// answer in prose, or be refused another round by a bound while it is
/// still gathering. Which bound that was is on the stream, as
/// [`Event::RunFinished`](crate::Event::RunFinished)'s `stopped_by` —
/// [`Bound::TokenBudget`](crate::Bound::TokenBudget) for an
/// allowance spent mid-gather — and only there, because the report that
/// would otherwise carry it is not handed back when there is no value to
/// hand back with it.
///
/// The stream is complete and closed in every one of those cases, so a sink
/// with somewhere to put events — a file, a channel — has the whole run.
/// Only the sink *value* is lost, because it comes back inside the report.
///
/// ```no_run
/// use serde::Deserialize;
/// use serde_json::json;
///
/// #[derive(Deserialize)]
/// struct Review {
/// verdict: String,
/// }
///
/// # async fn example(run: &mut basis::PreparedRun) -> Result<(), basis::RunError> {
/// let spec = basis::OutputSpec::new(
/// "submit_review",
/// "call this once you have weighed everything you read on the last turn",
/// json!({
/// "type": "object",
/// "properties": {
/// "verdict": { "type": "string", "description": "ship or hold" }
/// },
/// "required": ["verdict"]
/// }),
/// );
///
/// // The reading happened on an ordinary turn; this one only shapes it.
/// run.execute(basis::NullSink).await?;
/// let output = run
/// .output::<Review, _, _>(
/// "submit your review of what you just read",
/// spec,
/// basis::NullSink,
/// basis::AllowAll,
/// )
/// .await?;
///
/// // A value, not a paragraph to parse — and what it cost, for a caller
/// // adding runs up against a budget.
/// println!("{} ({} tokens)", output.value.verdict, output.report.usage.total_tokens());
/// # Ok(())
/// # }
/// ```
pub async fn output<T: DeserializeOwned, S: EventSink, A: Approver>(
&mut self,
prompt: impl Into<String>,
spec: OutputSpec,
sink: S,
approver: A,
) -> Result<OutputReport<T, S>, RunError> {
self.typed_turn(prompt.into(), spec, sink, approver, TurnOptions::default())
.await
}
/// A typed turn with explicit run options.
///
/// Same relationship to [`output`](Self::output) as
/// [`send_with_options`](Self::send_with_options) has to
/// [`send`](Self::send): a typed turn is cancellable and boundable like any
/// other, and a fan-out that gives each of its runs a deadline should not
/// have to give up types to get one.
pub async fn output_with_options<T: DeserializeOwned, S: EventSink, A: Approver>(
&mut self,
prompt: impl Into<String>,
spec: OutputSpec,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<OutputReport<T, S>, RunError> {
self.typed_turn(prompt.into(), spec, sink, approver, options)
.await
}
/// One typed turn. Identical to [`turn`](Self::turn) but for the one call
/// in the middle — which is the whole reason both are written this way,
/// since a second copy of the header-and-forwarding dance is a second thing
/// to keep in step with the stream contract.
///
/// mentra is asked for a [`Value`] rather than for `T` directly, and basis
/// deserializes. That costs nothing (the payload is already JSON) and buys
/// the error distinction: a value that does not fit `T` is basis's own
/// finding, reported as [`RunError::OutputMismatch`], instead of arriving
/// as one more `MalformedProviderEvent` indistinguishable from a provider
/// that misbehaved.
async fn typed_turn<T: DeserializeOwned, S: EventSink, A: Approver>(
&mut self,
prompt: String,
spec: OutputSpec,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<OutputReport<T, S>, RunError> {
let options = bounded(options, &self.bounds);
drawable(&options)?;
let parts = vec![PromptPart::Text(prompt)];
let turn = self.begin(&parts, sink, approver)?;
let (usage, usage_tap) = self.observe_usage();
// The same clone the untyped turn keeps, for the same reason: a typed
// turn is boundable like any other and owes the same account of why it
// ended.
let run_options = self.run_options(options);
let observed = run_options.clone();
let result = self
.session
.append_turn_to_output::<Value>(
prompt::into_blocks(parts),
run_options,
spec.into_terminal_spec(),
)
.await;
let usage = Self::finish_usage(usage, usage_tap);
let typed = match result {
Ok(output) => Ok(serde_json::from_value::<T>(output.value)),
Err(error) => Err(error),
};
let ended = match &typed {
Ok(Ok(_)) => Ended::Answered(None),
Ok(Err(mismatch)) => Ended::Mismatched(mismatch),
Err(error) => Ended::Failed(error),
};
let report = self.finish(turn, ended, &observed, usage).await?;
match typed {
Ok(Ok(value)) => Ok(OutputReport { value, report }),
Ok(Err(mismatch)) => Err(RunError::OutputMismatch(mismatch)),
Err(error) => Err(RunError::Runtime(error)),
}
}
}