- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! Doom-loop guard: the third identical (tool, args) call in one run is - 4
//! re-routed through approval instead of executing silently. - 5
- 6
use std::collections::VecDeque; - 7
use std::sync::{Arc, Mutex}; - 8
- 9
use tokio::sync::mpsc; - 10
use tokio_util::sync::CancellationToken; - 11
- 12
use tempfile::tempdir; - 13
- 14
use vak_agent::{Agent, AgentConfig, TurnOutcome}; - 15
use vak_llm::stream; - 16
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 17
use vak_llm::{EventStream, LlmError, Provider}; - 18
use vak_permission::PermissionEngine; - 19
use vak_session::types::{FrozenContract, SessionHeader}; - 20
use vak_session::{SessionLog, SessionPath}; - 21
use vak_tools::bash::BashTool; - 22
- 23
struct Scripted { - 24
responses: Mutex<VecDeque<AssistantMessage>>, - 25
} - 26
- 27
#[async_trait::async_trait] - 28
impl Provider for Scripted { - 29
fn name(&self) -> &str { - 30
"scripted" - 31
} - 32
- 33
async fn stream( - 34
&self, - 35
_request: ChatRequest, - 36
_cancel: CancellationToken, - 37
) -> Result<EventStream, LlmError> { - 38
let next = self.responses.lock().unwrap().pop_front(); - 39
let (mut sink, rx) = stream::channel(64); - 40
match next { - 41
Some(m) => { - 42
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 43
sink.close_message(m).await; - 44
} - 45
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 46
} - 47
Ok(rx) - 48
} - 49
} - 50
- 51
fn bash_call(id: &str, cmd: &str) -> AssistantMessage { - 52
AssistantMessage { - 53
content: vec![ContentBlock::ToolUse { - 54
id: id.into(), - 55
name: "bash".into(), - 56
input: serde_json::json!({"command": cmd}), - 57
}], - 58
stop_reason: StopReason::ToolUse, - 59
usage: Usage::default(), - 60
model: "test-model".into(), - 61
response_id: None, - 62
} - 63
} - 64
- 65
fn text_msg(t: &str) -> AssistantMessage { - 66
AssistantMessage { - 67
content: vec![ContentBlock::text(t)], - 68
stop_reason: StopReason::EndTurn, - 69
usage: Usage { - 70
input_tokens: 1, - 71
output_tokens: 1, - 72
..Default::default() - 73
}, - 74
model: "test-model".into(), - 75
response_id: None, - 76
} - 77
} - 78
- 79
#[tokio::test] - 80
async fn third_identical_call_is_blocked_with_reason() { - 81
let dir = tempdir().unwrap(); - 82
let header = SessionHeader { - 83
agent: None, - 84
session_id: "doom-loop".into(), - 85
created_at: chrono::Utc::now(), - 86
cwd: dir.path().to_path_buf(), - 87
parent_session_id: None, - 88
contract_id: None, - 89
work_item_id: None, - 90
conversation: None, - 91
contract: FrozenContract { - 92
app_version: "0".into(), - 93
provider: "scripted".into(), - 94
model: "test-model".into(), - 95
route_ladder: Vec::new(), - 96
route_objective: String::new(), - 97
route_annotations: Vec::new(), - 98
system_prompt: "sys".into(), - 99
permission_mode: "full-access".into(), - 100
capabilities: Vec::new(), - 101
prompt_layers: Vec::new(), - 102
}, - 103
}; - 104
let home = dir.path().join("home"); - 105
std::fs::create_dir_all(&home).unwrap(); - 106
let log = SessionLog::create( - 107
SessionPath::new_session_file(&home, dir.path(), "doom-loop"), - 108
header, - 109
) - 110
.unwrap(); - 111
- 112
let mut agent = Agent::new( - 113
Arc::new(Scripted { - 114
responses: Mutex::new(VecDeque::from(vec![ - 115
bash_call("a", "echo same"), - 116
bash_call("b", "echo same"), - 117
bash_call("c", "echo same"), - 118
text_msg("adapted after guard"), - 119
])), - 120
}), - 121
log, - 122
{ - 123
let mut cfg = AgentConfig::new("sys"); - 124
cfg.model = "test-model".into(); - 125
cfg.tools = vec![Arc::new(BashTool)]; - 126
cfg.mode = vak_permission::Mode::FullAccess; - 127
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 128
// AutoDeny makes the doom-loop Ask observable as a typed denial. - 129
cfg.approver = Some(Arc::new(vak_agent::AutoDeny)); - 130
cfg - 131
}, - 132
); - 133
- 134
let outcome = agent - 135
.run( - 136
"loop", - 137
&Default::default(), - 138
CancellationToken::new(), - 139
mpsc::channel(64).0, - 140
) - 141
.await; - 142
assert!( - 143
matches!(outcome, TurnOutcome::Completed { .. }), - 144
"got {outcome:?}" - 145
); - 146
- 147
// Raw ledger: the closed turn's results are trace lines in the - 148
// model-visible projection now (docs/design/68-context-engine.md - 149
// §10); this test is about what the guard actually recorded. - 150
let results: Vec<(bool, String)> = agent - 151
.session - 152
.lock() - 153
.await - 154
.message_chain() - 155
.iter() - 156
.flat_map(|(_, m)| m.content.iter()) - 157
.filter_map(|b| match b { - 158
ContentBlock::ToolResult { - 159
content, is_error, .. - 160
} => Some((*is_error, content.clone())), - 161
_ => None, - 162
}) - 163
.collect(); - 164
assert_eq!(results.len(), 3); - 165
assert!(!results[0].0 && !results[1].0, "first two run normally"); - 166
assert!(results[2].0, "third identical call must be blocked"); - 167
assert!( - 168
results[2].1.contains("repeated"), - 169
"guard reason must reach the model: {}", - 170
results[2].1 - 171
); - 172
} - 173
- 174
#[tokio::test] - 175
async fn different_args_are_not_counted_together() { - 176
let dir = tempdir().unwrap(); - 177
let header = SessionHeader { - 178
agent: None, - 179
session_id: "no-doom".into(), - 180
created_at: chrono::Utc::now(), - 181
cwd: dir.path().to_path_buf(), - 182
parent_session_id: None, - 183
contract_id: None, - 184
work_item_id: None, - 185
conversation: None, - 186
contract: FrozenContract { - 187
app_version: "0".into(), - 188
provider: "scripted".into(), - 189
model: "test-model".into(), - 190
route_ladder: Vec::new(), - 191
route_objective: String::new(), - 192
route_annotations: Vec::new(), - 193
system_prompt: "sys".into(), - 194
permission_mode: "full-access".into(), - 195
capabilities: Vec::new(), - 196
prompt_layers: Vec::new(), - 197
}, - 198
}; - 199
let home = dir.path().join("home"); - 200
std::fs::create_dir_all(&home).unwrap(); - 201
let log = SessionLog::create( - 202
SessionPath::new_session_file(&home, dir.path(), "no-doom"), - 203
header, - 204
) - 205
.unwrap(); - 206
- 207
let mut agent = Agent::new( - 208
Arc::new(Scripted { - 209
responses: Mutex::new(VecDeque::from(vec![ - 210
bash_call("a", "echo one"), - 211
bash_call("b", "echo two"), - 212
bash_call("c", "echo three"), - 213
text_msg("done"), - 214
])), - 215
}), - 216
log, - 217
{ - 218
let mut cfg = AgentConfig::new("sys"); - 219
cfg.model = "test-model".into(); - 220
cfg.tools = vec![Arc::new(BashTool)]; - 221
cfg.mode = vak_permission::Mode::FullAccess; - 222
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 223
cfg.approver = Some(Arc::new(vak_agent::AutoDeny)); - 224
cfg - 225
}, - 226
); - 227
- 228
let outcome = agent - 229
.run( - 230
"distinct commands", - 231
&Default::default(), - 232
CancellationToken::new(), - 233
mpsc::channel(64).0, - 234
) - 235
.await; - 236
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 237
for is_error in agent - 238
.session - 239
.lock() - 240
.await - 241
.derive_messages() - 242
.iter() - 243
.flat_map(|m| m.content.iter()) - 244
.filter_map(|b| match b { - 245
ContentBlock::ToolResult { is_error, .. } => Some(*is_error), - 246
_ => None, - 247
}) - 248
{ - 249
assert!(!is_error, "distinct calls must never trip the guard"); - 250
} - 251
} - 252
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.