basis 0.2.0

The basis SDK: workspace discovery, run lifecycle, one event stream, and the two seams. No protocol, no transport, no TTY.
Documentation
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
//! The approval loop, end to end.
//!
//! The property under test is that a consequential call is *answered*. mentra's
//! session authorizer blocks the turn on a oneshot until someone resolves the
//! request, so a harness that emits `permission_requested` without resolving
//! it does not merely lose a feature — it hangs. These tests fail by timing
//! out, which is exactly the failure they exist to catch.
//!
//! There is no policy to configure any more (ADR-0010): the gate surfaces every
//! consequential call and the approver answers all of it. So these drive the
//! approvers basis actually ships, rather than a stand-in for an enum that no
//! longer exists.

use std::{
    collections::VecDeque,
    path::Path,
    sync::{Arc, Mutex},
    time::Duration,
};

use async_trait::async_trait;
use basis::{
    AllowAll, ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver, CollectingSink, DenyAll,
    Event, RunConfig, approval::ApprovalGate, run::prepare_with_session,
};
use mentra::{
    BuiltinProvider, ContentBlock, ModelInfo, Role, Runtime, RuntimePolicy, Session,
    provider::{
        Provider, ProviderDescriptor, ProviderError, ProviderEventStream, Request, Response,
        provider_event_stream_from_response,
    },
    runtime::VolatileRuntimeStore,
};
use serde_json::json;

/// Every run here must finish well inside this; exceeding it means a request
/// went unanswered and the turn is stuck.
const NOT_STUCK: Duration = Duration::from_secs(10);

/// Replays a fixed script of assistant turns.
struct ScriptedProvider {
    model: ModelInfo,
    turns: Mutex<VecDeque<Vec<ContentBlock>>>,
}

impl ScriptedProvider {
    fn new(model: ModelInfo, turns: Vec<Vec<ContentBlock>>) -> Self {
        Self {
            model,
            turns: Mutex::new(turns.into()),
        }
    }
}

#[async_trait]
impl Provider for ScriptedProvider {
    fn descriptor(&self) -> ProviderDescriptor {
        ProviderDescriptor::new(self.model.provider.clone())
    }

    async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
        Ok(vec![self.model.clone()])
    }

    async fn stream(&self, _request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
        let content = self
            .turns
            .lock()
            .expect("not poisoned")
            .pop_front()
            .unwrap_or_else(|| vec![ContentBlock::text("done")]);

        Ok(provider_event_stream_from_response(Response {
            id: "scripted".to_string(),
            model: self.model.id.clone(),
            role: Role::Assistant,
            content,
            stop_reason: None,
            usage: None,
        }))
    }
}

/// A runtime whose first turn reads something and writes a file — one call the
/// gate lets through and one it must put to the approver.
fn runtime_writing_a_file(workspace: &Path) -> (Runtime, ModelInfo) {
    let model = ModelInfo::new("scripted-model", BuiltinProvider::OpenAI);
    let provider = ScriptedProvider::new(
        model.clone(),
        vec![
            vec![
                ContentBlock::ToolUse {
                    id: "call-0".to_string(),
                    name: "check_background".to_string(),
                    input: json!({}),
                },
                ContentBlock::ToolUse {
                    id: "call-1".to_string(),
                    name: "files".to_string(),
                    input: json!({
                        "operations": [
                            { "op": "create", "path": "made.txt", "content": "hi" }
                        ]
                    }),
                },
            ],
            vec![ContentBlock::text("done")],
        ],
    );

    let runtime = Runtime::builder()
        .with_provider_instance(provider)
        // Nothing here reads a conversation back, so the history has nowhere
        // to be: mentra's in-memory store keeps this suite off the disk
        // entirely rather than leaving a temp database per test behind.
        .with_store(VolatileRuntimeStore::new())
        .with_policy(RuntimePolicy::workspace_bounded(workspace))
        .with_tool_authorizer(ApprovalGate::new())
        .build()
        .expect("runtime builds");

    (runtime, model)
}

fn session(runtime: &Runtime, workspace: &Path, model: ModelInfo) -> Session {
    runtime
        .create_session_with_config(
            "test",
            model,
            mentra::agent::AgentConfig {
                workspace: mentra::agent::WorkspaceConfig {
                    base_dir: workspace.to_path_buf(),
                    ..Default::default()
                },
                ..Default::default()
            },
        )
        .expect("session")
}

fn config(workspace: &Path) -> RunConfig {
    RunConfig::new(workspace, "make a file").with_context(basis::ContextConfig {
        file_name: "AGENTS.md".to_string(),
        global_dir: None,
        walk_parents: false,
    })
}

/// Records what it was asked, then lets the approver under test answer.
struct Recording<A> {
    inner: A,
    seen: Arc<Mutex<Vec<ApprovalRequest>>>,
}

#[async_trait]
impl<A: Approver> Approver for Recording<A> {
    async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
        self.seen
            .lock()
            .expect("not poisoned")
            .push(request.clone());
        self.inner.approve(request).await
    }
}

/// Runs the scripted turn under `approver`, reporting the stream and every
/// request the approver was put.
async fn run_with<A: Approver>(
    workspace: &Path,
    approver: A,
) -> (Vec<Event>, Vec<ApprovalRequest>) {
    let (runtime, model) = runtime_writing_a_file(workspace);
    let session = session(&runtime, workspace, model);
    let seen = Arc::new(Mutex::new(Vec::new()));

    let mut prepared =
        prepare_with_session(session, &config(workspace), "openai", "scripted-model")
            .expect("prepared");

    let report = tokio::time::timeout(
        NOT_STUCK,
        prepared.execute_with_approver(
            CollectingSink::new(),
            Recording {
                inner: approver,
                seen: Arc::clone(&seen),
            },
        ),
    )
    .await
    .expect("the run must not hang waiting on an unanswered approval")
    .expect("the run completes");

    let asked = seen.lock().expect("not poisoned").clone();
    (report.sink.into_events(), asked)
}

/// Whether the named tool reported an error, or `None` if it never completed.
fn tool_failed(events: &[Event], tool: &str) -> Option<bool> {
    events.iter().find_map(|event| match event {
        Event::ToolCompleted {
            tool_name,
            is_error,
            ..
        } if tool_name == tool => Some(*is_error),
        _ => None,
    })
}

/// The result text the named tool produced — the same string the model reads
/// back as that call's outcome.
fn tool_result(events: &[Event], tool: &str) -> Option<String> {
    events.iter().find_map(|event| match event {
        Event::ToolCompleted {
            tool_name, summary, ..
        } if tool_name == tool => Some(summary.clone()),
        _ => None,
    })
}

fn asked_about(asked: &[ApprovalRequest]) -> Vec<&str> {
    asked.iter().map(|request| &*request.tool_name).collect()
}

#[tokio::test]
async fn an_approved_call_happens_rather_than_hanging() {
    let workspace = tempfile::tempdir().expect("tempdir");

    let (events, asked) = run_with(workspace.path(), AllowAll).await;

    assert_eq!(
        asked_about(&asked),
        vec!["files"],
        "the write should have been put to the approver"
    );
    assert!(
        events
            .iter()
            .any(|event| matches!(event, Event::PermissionRequested { .. })),
        "the request must also reach the stream"
    );
    assert!(
        events
            .iter()
            .any(|event| matches!(event, Event::PermissionResolved { .. })),
        "and its resolution must too"
    );
    assert_eq!(
        tool_failed(&events, "files"),
        Some(false),
        "an approved call runs"
    );
    assert!(
        workspace.path().join("made.txt").exists(),
        "an approved write must actually happen"
    );
}

#[tokio::test]
async fn a_refused_call_does_not_happen() {
    let workspace = tempfile::tempdir().expect("tempdir");

    let (events, asked) = run_with(workspace.path(), DenyAll).await;

    assert_eq!(asked_about(&asked), vec!["files"]);
    assert_eq!(
        tool_failed(&events, "files"),
        Some(true),
        "a refused call fails, and the model reads why"
    );
    assert!(
        !workspace.path().join("made.txt").exists(),
        "a refused write must not reach the disk"
    );
}

#[tokio::test]
async fn a_refusal_tells_the_model_what_the_run_does_not_allow() {
    // The whole point of the reason: it is the tool result the model reads,
    // so a read-only run says so once instead of watching the model retry the
    // same write. Pinned verbatim because paraphrase here is a silent
    // regression — the string is the interface.
    let workspace = tempfile::tempdir().expect("tempdir");

    let (events, _asked) = run_with(workspace.path(), DenyAll).await;

    assert_eq!(
        tool_result(&events, "files").as_deref(),
        Some(
            "Tool execution denied: files changes state outside this process, \
             which this run does not allow"
        )
    );
}

#[tokio::test]
async fn a_refusal_with_nothing_to_say_still_refuses() {
    // An approver that gives no reason is still fail-closed; the model just
    // gets mentra's standing wording rather than basis's.
    struct Silent;

    #[async_trait]
    impl Approver for Silent {
        async fn approve(&mut self, _request: &ApprovalRequest) -> ApprovalAnswer {
            ApprovalAnswer::new(ApprovalDecision::Deny)
        }
    }

    let workspace = tempfile::tempdir().expect("tempdir");

    let (events, _asked) = run_with(workspace.path(), Silent).await;

    assert_eq!(
        tool_result(&events, "files").as_deref(),
        Some("Tool execution denied: denied by session approver")
    );
    assert!(
        !workspace.path().join("made.txt").exists(),
        "a refused write must not reach the disk, reason or no reason"
    );
}

#[tokio::test]
async fn a_read_only_call_is_never_put_to_the_approver() {
    let workspace = tempfile::tempdir().expect("tempdir");

    // Under the strictest approver there is: a read that reached it would be
    // denied, so this catches both halves of the rule at once.
    let (events, asked) = run_with(workspace.path(), DenyAll).await;

    assert!(
        !asked_about(&asked).contains(&"check_background"),
        "prompting for reads trains people to approve without reading: {:?}",
        asked_about(&asked)
    );
    assert_eq!(
        tool_failed(&events, "check_background"),
        Some(false),
        "and a read must still run while everything else is refused"
    );
}

#[tokio::test]
async fn a_run_with_no_approver_of_its_own_allows_what_it_cannot_ask_about() {
    // What `run` gives a headless caller: nobody to ask, so nothing is refused
    // for want of an answer. `execute` is `execute_with_approver(_, AllowAll)`.
    let workspace = tempfile::tempdir().expect("tempdir");
    let (runtime, model) = runtime_writing_a_file(workspace.path());
    let session = session(&runtime, workspace.path(), model);

    let mut prepared = prepare_with_session(
        session,
        &config(workspace.path()),
        "openai",
        "scripted-model",
    )
    .expect("prepared");

    let report = tokio::time::timeout(NOT_STUCK, prepared.execute(CollectingSink::new()))
        .await
        .expect("the run must not hang waiting on an unanswered approval")
        .expect("the run completes");

    assert_eq!(
        tool_failed(&report.sink.into_events(), "files"),
        Some(false)
    );
    assert!(workspace.path().join("made.txt").exists());
}

#[tokio::test]
async fn a_broken_sink_stops_the_narration_and_not_the_turn() {
    // `basis spawn --json | head`: stdout closes mid-run. Every consequential call
    // now waits on the task that writes those events, so a forwarder that gave
    // up on the first failed write would leave the turn blocked on a permission
    // nobody was left to answer.
    let workspace = tempfile::tempdir().expect("tempdir");
    let (runtime, model) = runtime_writing_a_file(workspace.path());
    let session = session(&runtime, workspace.path(), model);

    let mut written = 0;
    let sink = basis::run::FnSink::new(move |_event| {
        written += 1;
        match written {
            // The header goes through: a run whose first write fails never
            // starts, which is a different story than this one.
            1 => Ok(()),
            _ => Err(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                "the reader went away",
            )),
        }
    });

    let mut prepared = prepare_with_session(
        session,
        &config(workspace.path()),
        "openai",
        "scripted-model",
    )
    .expect("prepared");

    let result = tokio::time::timeout(NOT_STUCK, prepared.execute_with_approver(sink, AllowAll))
        .await
        .expect("a dead reader must not hang the run");

    assert!(
        matches!(result, Err(basis::RunError::Sink(_))),
        "the broken pipe is still reported"
    );
    assert!(
        workspace.path().join("made.txt").exists(),
        "and the approved write happened anyway"
    );
}

#[tokio::test]
async fn the_approver_is_told_what_the_tool_would_do() {
    let workspace = tempfile::tempdir().expect("tempdir");

    let (_, asked) = run_with(workspace.path(), AllowAll).await;

    let request = &asked[0];
    assert!(!request.request_id.is_empty());
    assert_eq!(request.tool_call_id, "call-1");
    assert!(
        request.description.contains("files"),
        "the description should name the tool: {:?}",
        request.description
    );
    assert_eq!(
        request.input["operations"][0]["op"], "create",
        "input must arrive as JSON so an approver can show what changes"
    );
}