saya-agent 0.4.0

Agentic LLM loop and OpenAI-compatible provider clients for SAYA CLI.
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
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
//! Phase 3a: the loop's fail-closed guard for `WriteCandidate` tools.
//!
//! A tool declaring `LocalStateEffect::WriteCandidate` must be *denied* (a
//! `ToolDenied` event with a reason, the turn continuing) when the runner was
//! not constructed with candidate writes permitted, and must run normally when
//! the permission is on. Tools declaring `None` or `Read` are unaffected either
//! way. See .claude/specs/spec-3a-local-state-effect.md §3–§4.

use async_trait::async_trait;
use saya_agent::{
    AgentEvent, AgentEventSink, AgentLimits, AgentRequest, AllowReadOnlyApproval, ApprovalDecider,
    ChatProvider, ChatRequest, ChatResponse, LocalStateEffect, ToolCall, ToolDefinition,
    ToolEffect, ToolError, ToolExecutor, run_agent_with_sink,
};
use std::sync::{Arc, Mutex};

/// A provider that emits one tool call on its first `stream` invocation and a
/// plain text answer on every subsequent one, so a run that survives a denial
/// (or executes the tool) completes instead of looping on the turn limit.
/// `stream` is called once per turn, so the counter distinguishes them.
struct OneCallProvider {
    call: ToolCall,
    turn: Mutex<u32>,
}

#[async_trait]
impl ChatProvider for OneCallProvider {
    fn name(&self) -> &str {
        "one-call-mock"
    }
    async fn complete(&self, _: ChatRequest) -> Result<ChatResponse, saya_agent::ProviderError> {
        unreachable!("stream path is used")
    }
    async fn stream(
        &self,
        _: ChatRequest,
        _: saya_agent::CancellationToken,
    ) -> Result<saya_agent::ProviderStream, saya_agent::ProviderError> {
        let first = {
            let mut turn = self.turn.lock().unwrap();
            let was = *turn;
            *turn += 1;
            was == 0
        };
        let events = if first {
            vec![
                Ok(saya_agent::ProviderEvent::ToolCalls(vec![
                    self.call.clone(),
                ])),
                Ok(saya_agent::ProviderEvent::Done),
            ]
        } else {
            vec![
                Ok(saya_agent::ProviderEvent::TextDelta("done".into())),
                Ok(saya_agent::ProviderEvent::Done),
            ]
        };
        Ok(Box::pin(futures_util::stream::iter(events)))
    }
}

struct RecordingExecutor {
    calls: Arc<Mutex<Vec<String>>>,
}

#[async_trait]
impl ToolExecutor for RecordingExecutor {
    async fn execute(
        &self,
        name: &str,
        _: serde_json::Value,
    ) -> Result<serde_json::Value, ToolError> {
        self.calls.lock().unwrap().push(name.into());
        Ok(serde_json::json!({"ok": true}))
    }
}

struct RecordingSink {
    events: Arc<Mutex<Vec<AgentEvent>>>,
}

#[async_trait]
impl AgentEventSink for RecordingSink {
    async fn emit(&self, event: AgentEvent) {
        self.events.lock().unwrap().push(event);
    }
}

fn candidate_tool() -> ToolDefinition {
    ToolDefinition {
        name: "remember_candidate".into(),
        description: "may persist a candidate claim".into(),
        read_only: false,
        parameters: serde_json::json!({"type": "object"}),
        effect: ToolEffect {
            database_data: false,
            external_side_effect: false,
            requires_approval: false,
            local_state: LocalStateEffect::WriteCandidate,
        },
    }
}

fn request() -> AgentRequest {
    AgentRequest {
        prompt: "remember something".into(),
        profile_names: vec!["analytics".into()],
        model: "mock-model".into(),
        system_prompt: None,
        history: Vec::new(),
        context_blocks: Vec::new(),
    }
}

/// A `WriteCandidate` tool is denied — not executed — when candidate writes
/// are not permitted (the default), surfacing as a `ToolDenied` event with a
/// reason, and the turn continues to completion.
#[tokio::test]
async fn write_candidate_tool_is_denied_by_default_and_does_not_end_the_turn() {
    let calls = Arc::new(Mutex::new(Vec::new()));
    let events = Arc::new(Mutex::new(Vec::new()));
    let provider = OneCallProvider {
        call: ToolCall {
            id: "c1".into(),
            name: "remember_candidate".into(),
            arguments: serde_json::json!({}),
        },
        turn: Mutex::new(0),
    };
    let sink = RecordingSink {
        events: events.clone(),
    };
    let token = saya_agent::CancellationToken::new();
    let output = run_agent_with_sink(
        &provider,
        &RecordingExecutor {
            calls: calls.clone(),
        },
        request(),
        vec![candidate_tool()],
        AgentLimits::default(),
        &AllowReadOnlyApproval,
        &sink,
        token,
    )
    .await
    .expect("denial is not a turn-ending error");
    assert!(
        calls.lock().unwrap().is_empty(),
        "the tool must not execute when candidate writes are not permitted"
    );
    let denied = events.lock().unwrap().iter().find_map(|event| match event {
        AgentEvent::ToolDenied { name, reason } => Some((name.clone(), reason.clone())),
        _ => None,
    });
    let (name, reason) = denied.expect("a ToolDenied event must be emitted");
    assert_eq!(name, "remember_candidate");
    assert!(
        !reason.is_empty(),
        "the denial must carry a clear reason, not be a silent skip"
    );
    // The turn continued past the denial to a normal completion.
    assert!(
        output
            .events
            .iter()
            .any(|event| matches!(event, AgentEvent::Complete)),
        "the turn must complete, not end on the denial"
    );
    assert_eq!(output.tool_metadata[0].name, "remember_candidate");
    assert_eq!(output.tool_metadata[0].status, "denied");
}

/// The same `WriteCandidate` tool executes normally when the permission is on.
#[tokio::test]
async fn write_candidate_tool_runs_when_candidate_writes_are_permitted() {
    let calls = Arc::new(Mutex::new(Vec::new()));
    let events = Arc::new(Mutex::new(Vec::new()));
    let provider = OneCallProvider {
        call: ToolCall {
            id: "c1".into(),
            name: "remember_candidate".into(),
            arguments: serde_json::json!({}),
        },
        turn: Mutex::new(0),
    };
    let sink = RecordingSink {
        events: events.clone(),
    };
    let token = saya_agent::CancellationToken::new();
    let limits = AgentLimits {
        permit_candidate_writes: true,
        ..AgentLimits::default()
    };
    let output = run_agent_with_sink(
        &provider,
        &RecordingExecutor {
            calls: calls.clone(),
        },
        request(),
        vec![candidate_tool()],
        limits,
        &AllowReadOnlyApproval,
        &sink,
        token,
    )
    .await
    .expect("run completes");
    assert_eq!(&*calls.lock().unwrap(), &["remember_candidate"]);
    assert!(
        !output
            .events
            .iter()
            .any(|event| matches!(event, AgentEvent::ToolDenied { .. })),
        "no denial when candidate writes are permitted"
    );
    assert_eq!(output.tool_metadata[0].status, "completed");
}

/// A read-only local-state tool is unaffected by the permission either way.
#[tokio::test]
async fn read_local_state_tool_is_unaffected_by_the_candidate_permission() {
    let calls = Arc::new(Mutex::new(Vec::new()));
    let events = Arc::new(Mutex::new(Vec::new()));
    let provider = OneCallProvider {
        call: ToolCall {
            id: "c1".into(),
            name: "contract_search".into(),
            arguments: serde_json::json!({}),
        },
        turn: Mutex::new(0),
    };
    let sink = RecordingSink {
        events: events.clone(),
    };
    let token = saya_agent::CancellationToken::new();
    let read_tool = ToolDefinition {
        name: "contract_search".into(),
        description: "reads local contracts".into(),
        read_only: true,
        parameters: serde_json::json!({"type": "object"}),
        effect: ToolEffect {
            database_data: false,
            external_side_effect: false,
            requires_approval: false,
            local_state: LocalStateEffect::Read,
        },
    };
    // Default (not permitted) — a Read tool must still run.
    let _ = run_agent_with_sink(
        &provider,
        &RecordingExecutor {
            calls: calls.clone(),
        },
        request(),
        vec![read_tool.clone()],
        AgentLimits::default(),
        &AllowReadOnlyApproval,
        &sink,
        token,
    )
    .await
    .expect("run completes");
    assert_eq!(&*calls.lock().unwrap(), &["contract_search"]);
    assert!(
        !events
            .lock()
            .unwrap()
            .iter()
            .any(|event| matches!(event, AgentEvent::ToolDenied { .. })),
        "a Read tool must not be denied by the candidate-write guard"
    );
}

/// A decider that refuses every call, so a `requires_approval` tool is always
/// denied at the prompt — used to prove the approval gate still fires for an
/// external-side-effect tool that set `requires_approval`.
struct DenyApproval;

#[async_trait]
impl ApprovalDecider for DenyApproval {
    async fn approve(&self, _: &ToolDefinition, _: &serde_json::Value) -> bool {
        false
    }
}

/// A tool that declares `external_side_effect` *without* also
/// declaring `requires_approval` is a misconfiguration the policy refuses to
/// auto-run, rather than trusting the author to set both. The refusal surfaces
/// as a `ToolDenied` event with a reason naming the side effect, and the tool
/// never executes. This is the test that would catch a future tool setting
/// only one of the two flags.
#[tokio::test]
async fn external_side_effect_without_approval_is_refused_not_auto_run() {
    let calls = Arc::new(Mutex::new(Vec::new()));
    let events = Arc::new(Mutex::new(Vec::new()));
    let provider = OneCallProvider {
        call: ToolCall {
            id: "c1".into(),
            name: "open_browser".into(),
            arguments: serde_json::json!({}),
        },
        turn: Mutex::new(0),
    };
    let sink = RecordingSink {
        events: events.clone(),
    };
    let external_only = ToolDefinition {
        name: "open_browser".into(),
        description: "opens something outside the agent".into(),
        read_only: true,
        parameters: serde_json::json!({"type": "object"}),
        effect: ToolEffect {
            database_data: false,
            external_side_effect: true,
            requires_approval: false,
            local_state: LocalStateEffect::None,
        },
    };
    let _ = run_agent_with_sink(
        &provider,
        &RecordingExecutor {
            calls: calls.clone(),
        },
        request(),
        vec![external_only],
        AgentLimits::default(),
        &AllowReadOnlyApproval,
        &sink,
        saya_agent::CancellationToken::new(),
    )
    .await
    .expect("a denial is not a turn-ending error");
    assert!(
        calls.lock().unwrap().is_empty(),
        "the misconfigured tool must not execute"
    );
    let reason = events
        .lock()
        .unwrap()
        .iter()
        .find_map(|event| match event {
            AgentEvent::ToolDenied { name, reason } if name == "open_browser" => {
                Some(reason.clone())
            }
            _ => None,
        })
        .expect("a ToolDenied event must be emitted");
    assert!(
        reason.contains("side effect"),
        "the reason must name the external side effect, got: {reason}"
    );
}

/// The external-side-effect gate must NOT double-deny a tool
/// that also requires approval and was approved — that is `render_chart`'s
/// shape (`external_side_effect: true, requires_approval: true`). Approval is
/// the real gate there; when granted, the tool runs. This preserves today's
/// behaviour for the only real tool that sets `external_side_effect`.
#[tokio::test]
async fn external_side_effect_with_approval_runs_when_approved() {
    let calls = Arc::new(Mutex::new(Vec::new()));
    let events = Arc::new(Mutex::new(Vec::new()));
    let provider = OneCallProvider {
        call: ToolCall {
            id: "c1".into(),
            name: "render_chart".into(),
            arguments: serde_json::json!({}),
        },
        turn: Mutex::new(0),
    };
    let sink = RecordingSink {
        events: events.clone(),
    };
    let render_chart = ToolDefinition {
        name: "render_chart".into(),
        description: "visualise a query".into(),
        read_only: true,
        parameters: serde_json::json!({"type": "object"}),
        effect: ToolEffect {
            database_data: false,
            external_side_effect: true,
            requires_approval: true,
            local_state: LocalStateEffect::None,
        },
    };
    let _ = run_agent_with_sink(
        &provider,
        &RecordingExecutor {
            calls: calls.clone(),
        },
        request(),
        vec![render_chart],
        AgentLimits::default(),
        &AllowReadOnlyApproval,
        &sink,
        saya_agent::CancellationToken::new(),
    )
    .await
    .expect("run completes");
    assert_eq!(&*calls.lock().unwrap(), &["render_chart"]);
    assert!(
        !events
            .lock()
            .unwrap()
            .iter()
            .any(|event| matches!(event, AgentEvent::ToolDenied { .. })),
        "an approved external-side-effect tool must run, not be denied"
    );
}

/// The same `render_chart`-shaped tool is still denied when
/// approval is refused — the approval gate is the binding one, and the
/// external-side-effect gate does not replace it.
#[tokio::test]
async fn external_side_effect_with_approval_is_denied_when_approval_refused() {
    let calls = Arc::new(Mutex::new(Vec::new()));
    let events = Arc::new(Mutex::new(Vec::new()));
    let provider = OneCallProvider {
        call: ToolCall {
            id: "c1".into(),
            name: "render_chart".into(),
            arguments: serde_json::json!({}),
        },
        turn: Mutex::new(0),
    };
    let sink = RecordingSink {
        events: events.clone(),
    };
    let render_chart = ToolDefinition {
        name: "render_chart".into(),
        description: "visualise a query".into(),
        read_only: true,
        parameters: serde_json::json!({"type": "object"}),
        effect: ToolEffect {
            database_data: false,
            external_side_effect: true,
            requires_approval: true,
            local_state: LocalStateEffect::None,
        },
    };
    let _ = run_agent_with_sink(
        &provider,
        &RecordingExecutor {
            calls: calls.clone(),
        },
        request(),
        vec![render_chart],
        AgentLimits::default(),
        &DenyApproval,
        &sink,
        saya_agent::CancellationToken::new(),
    )
    .await
    .expect("a denial is not a turn-ending error");
    assert!(
        calls.lock().unwrap().is_empty(),
        "the tool must not execute when approval is refused"
    );
    let reason = events
        .lock()
        .unwrap()
        .iter()
        .find_map(|event| match event {
            AgentEvent::ToolDenied { name, reason } if name == "render_chart" => {
                Some(reason.clone())
            }
            _ => None,
        })
        .expect("a ToolDenied event must be emitted");
    assert!(
        reason.contains("approval"),
        "the reason must name approval as the refusing gate, got: {reason}"
    );
}