harness-rs-loop 0.0.50

ReAct agent loop for harness-rs with measured guards: stuck detection, acceptance checks, a tool-result ceiling that spills oversized payloads to disk instead of truncating, per-call tool deadlines, subagent isolation, and session record/replay (JSONL).
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
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
//! The gate the agent cannot edit its way through.
//!
//! Every test here runs the real loop against a model that behaves the way the
//! failure actually shows up in practice: it cannot satisfy the check, so it
//! rewrites the check and reports success. Asserting on `SealSet` alone would
//! not prove the loop refuses the pass, which is the part that matters.

use async_trait::async_trait;
use harness_context::default_world;
use harness_core::{
    Context, Model, ModelError, ModelInfo, ModelOutput, StopReason, Task, ToolCall, ToolError,
    ToolResult, ToolRisk, ToolSchema, World,
};
use harness_core::{Event, Hook, HookOutcome};
use harness_loop::{Acceptance, AgentLoop, Outcome, Verdict};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

/// The contract: this file must contain exactly `42`.
///
/// Deliberately data-driven rather than hard-coded, because a contract kept in
/// a file is the one an agent with filesystem tools can reach — which is the
/// situation being defended against.
struct ExpectFortyTwo {
    contract: PathBuf,
}

#[async_trait]
impl Acceptance for ExpectFortyTwo {
    fn name(&self) -> &str {
        "expect-42"
    }
    fn seals(&self) -> Vec<PathBuf> {
        vec![self.contract.clone()]
    }
    async fn check(&self, _ctx: &Context, world: &World) -> Verdict {
        let want = std::fs::read_to_string(world.repo.root.join(&self.contract))
            .unwrap_or_default()
            .trim()
            .to_string();
        let got = std::fs::read_to_string(world.repo.root.join("answer.txt"))
            .unwrap_or_default()
            .trim()
            .to_string();
        if !want.is_empty() && want == got {
            Verdict::passed()
        } else {
            Verdict::failed(format!("answer.txt must contain {want:?}, found {got:?}"))
        }
    }
}

/// Writes whatever it is told, wherever it is told.
struct WriteFile;

#[async_trait]
impl harness_core::Tool for WriteFile {
    fn name(&self) -> &str {
        "write"
    }
    fn schema(&self) -> &ToolSchema {
        static S: std::sync::OnceLock<ToolSchema> = std::sync::OnceLock::new();
        S.get_or_init(|| ToolSchema {
            name: "write".into(),
            description: "Write text to a path, or delete it with text=\\0rm.".into(),
            input: serde_json::json!({
                "type": "object",
                "properties": { "path": {"type": "string"}, "text": {"type": "string"} },
                "required": ["path", "text"]
            }),
        })
    }
    fn risk(&self) -> ToolRisk {
        ToolRisk::Destructive
    }
    async fn invoke(
        &self,
        args: serde_json::Value,
        world: &mut World,
    ) -> Result<ToolResult, ToolError> {
        let p = args["path"].as_str().unwrap_or_default();
        let t = args["text"].as_str().unwrap_or_default();
        if t == "\0rm" {
            std::fs::remove_file(world.repo.root.join(p))
                .map_err(|e| ToolError::Exec(format!("rm {p}: {e}")))?;
        } else {
            std::fs::write(world.repo.root.join(p), t)
                .map_err(|e| ToolError::Exec(format!("write {p}: {e}")))?;
        }
        Ok(ToolResult {
            ok: true,
            content: serde_json::json!({ "wrote": p }),
            trace: None,
        })
    }
}

/// A model that plays a fixed sequence of moves, then stops.
struct Scripted {
    moves: Vec<(&'static str, &'static str)>,
    at: AtomicU32,
}

impl Scripted {
    fn new(moves: Vec<(&'static str, &'static str)>) -> Self {
        Self {
            moves,
            at: AtomicU32::new(0),
        }
    }
}

#[async_trait]
impl Model for Scripted {
    fn info(&self) -> ModelInfo {
        ModelInfo {
            handle: "scripted".into(),
            provider: "test".into(),
            model: "scripted".into(),
            context_window: 8192,
            input_cost_usd_per_million_tokens: None,
            output_cost_usd_per_million_tokens: None,
            supports_tool_use: true,
            supports_streaming: false,
            supports_web_grounding: false,
        }
    }
    async fn complete(&self, _ctx: &Context) -> Result<ModelOutput, ModelError> {
        let i = self.at.fetch_add(1, Ordering::SeqCst) as usize;
        match self.moves.get(i) {
            Some((path, text)) => Ok(ModelOutput {
                text: Some("working".into()),
                tool_calls: vec![ToolCall {
                    id: format!("c{i}"),
                    name: "write".into(),
                    args: serde_json::json!({ "path": path, "text": text }),
                }],
                stop_reason: StopReason::ToolUse,
                ..Default::default()
            }),
            None => Ok(ModelOutput {
                text: Some("done".into()),
                ..Default::default()
            }),
        }
    }
}

fn workspace(tag: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("harness-sealed-{}-{tag}", std::process::id()));
    let _ = std::fs::remove_dir_all(&d);
    std::fs::create_dir_all(&d).unwrap();
    std::fs::write(d.join("contract.txt"), "42").unwrap();
    d
}

fn run(dir: &PathBuf, moves: Vec<(&'static str, &'static str)>) -> Outcome {
    let loop_ = AgentLoop::new(Scripted::new(moves))
        .with_tool(Arc::new(WriteFile))
        .with_acceptance_set(vec![Arc::new(ExpectFortyTwo {
            contract: PathBuf::from("contract.txt"),
        })])
        .with_acceptance_retries(3);
    let mut world = default_world(dir);
    let task = Task {
        description: "make answer.txt satisfy the contract".into(),
        source: None,
        deadline: None,
    };
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(loop_.run(task, &mut world))
        .unwrap()
}

#[test]
fn doing_the_work_passes() {
    let d = workspace("honest");
    let out = run(&d, vec![("answer.txt", "42")]);
    match out {
        Outcome::Done {
            verified,
            seal_breach,
            ..
        } => {
            assert!(verified.as_ref().is_some_and(|v| v.passed), "{verified:?}");
            assert!(seal_breach.is_none());
        }
        other => panic!("expected Done, got {other:?}"),
    }
    let _ = std::fs::remove_dir_all(&d);
}

#[test]
fn editing_the_contract_to_match_a_wrong_answer_is_refused() {
    // The whole point. The model cannot produce 42, so it rewrites the
    // contract to demand the answer it *did* produce. Every check now passes
    // on its own terms, and the run must still fail.
    let d = workspace("forged");
    let out = run(&d, vec![("answer.txt", "wrong"), ("contract.txt", "wrong")]);
    match out {
        Outcome::Done {
            verified,
            seal_breach,
            ..
        } => {
            assert!(
                !verified.as_ref().is_some_and(|v| v.passed),
                "a forged contract must not yield a pass: {verified:?}"
            );
            let breach = seal_breach.expect("the breach must be reported, not just the failure");
            assert!(breach.contains("contract.txt"), "{breach}");
            assert!(breach.contains("was modified"), "{breach}");
        }
        other => panic!("expected Done, got {other:?}"),
    }
    let _ = std::fs::remove_dir_all(&d);
}

#[test]
fn deleting_the_contract_is_refused_too() {
    // `rm` must not be the cheap way through. The check here waves everything
    // past, so the seal is the only thing that can fail the run — and the
    // deletion happens through a tool call mid-run, which is how it would.
    let d = workspace("deleted");
    let loop_ = AgentLoop::new(Scripted::new(vec![("contract.txt", "\0rm")]))
        .with_tool(Arc::new(WriteFile))
        .with_acceptance_set(vec![Arc::new(AlwaysPasses {
            contract: PathBuf::from("contract.txt"),
        })])
        .with_acceptance_retries(0);
    let mut world = default_world(&d);
    let task = Task {
        description: "anything".into(),
        source: None,
        deadline: None,
    };
    let out = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(loop_.run(task, &mut world))
        .unwrap();
    match out {
        Outcome::Done {
            verified,
            seal_breach,
            ..
        } => {
            let b = seal_breach.expect("deletion must breach the seal");
            assert!(b.contains("was deleted"), "{b}");
            assert!(
                !verified.as_ref().is_some_and(|v| v.passed),
                "a check that passes must still not carry a breached run"
            );
        }
        other => panic!("expected Done, got {other:?}"),
    }
    let _ = std::fs::remove_dir_all(&d);
}

/// A check that would wave anything through — so the only thing that can fail
/// the run is the seal.
struct AlwaysPasses {
    contract: PathBuf,
}

#[async_trait]
impl Acceptance for AlwaysPasses {
    fn name(&self) -> &str {
        "always"
    }
    fn seals(&self) -> Vec<PathBuf> {
        vec![self.contract.clone()]
    }
    async fn check(&self, _c: &Context, _w: &World) -> Verdict {
        Verdict::passed()
    }
}

#[test]
fn a_check_that_seals_nothing_behaves_exactly_as_before() {
    // Sealing is opt-in; the default path must not change. A run that rewrites
    // its own tests on purpose is legitimate and must still pass.
    struct Unsealed;
    #[async_trait]
    impl Acceptance for Unsealed {
        fn name(&self) -> &str {
            "unsealed"
        }
        async fn check(&self, _c: &Context, _w: &World) -> Verdict {
            Verdict::passed()
        }
    }
    let d = workspace("unsealed");
    let loop_ = AgentLoop::new(Scripted::new(vec![("contract.txt", "rewritten")]))
        .with_tool(Arc::new(WriteFile))
        .with_acceptance_set(vec![Arc::new(Unsealed)]);
    let mut world = default_world(&d);
    let task = Task {
        description: "rewrite the contract, legitimately".into(),
        source: None,
        deadline: None,
    };
    let out = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(loop_.run(task, &mut world))
        .unwrap();
    match out {
        Outcome::Done {
            verified,
            seal_breach,
            ..
        } => {
            assert!(verified.as_ref().is_some_and(|v| v.passed));
            assert!(seal_breach.is_none(), "nothing was sealed");
        }
        other => panic!("expected Done, got {other:?}"),
    }
    let _ = std::fs::remove_dir_all(&d);
}

/// Records the gate events a host would be watching for.
#[derive(Default)]
struct Watcher {
    seen: std::sync::Mutex<Vec<String>>,
}

impl Hook for Watcher {
    fn name(&self) -> &str {
        "watcher"
    }
    fn matches(&self, ev: &Event<'_>) -> bool {
        matches!(
            ev,
            Event::AcceptanceChecked { .. } | Event::SealBreached { .. }
        )
    }
    fn fire(&self, ev: &Event<'_>, _w: &mut World) -> HookOutcome {
        let mut g = self.seen.lock().unwrap();
        match ev {
            Event::AcceptanceChecked { name, passed, .. } => {
                g.push(format!("checked:{name}:{passed}"))
            }
            Event::SealBreached { detail } => g.push(format!("breach:{detail}")),
            _ => {}
        }
        HookOutcome::Allow
    }
}

#[test]
fn the_gate_reports_to_hooks_not_only_to_the_caller() {
    // An audit trail that lists every tool the agent called and never says
    // whether anything agreed the work was done answers the wrong question.
    // Before this, no hook could see a verdict at all.
    let d = workspace("observed");
    let w = Arc::new(Watcher::default());
    let loop_ = AgentLoop::new(Scripted::new(vec![
        ("answer.txt", "wrong"),
        ("contract.txt", "wrong"),
    ]))
    .with_tool(Arc::new(WriteFile))
    .with_hook(w.clone())
    .with_acceptance_set(vec![Arc::new(ExpectFortyTwo {
        contract: PathBuf::from("contract.txt"),
    })])
    .with_acceptance_retries(0);
    let mut world = default_world(&d);
    let task = Task {
        description: "t".into(),
        source: None,
        deadline: None,
    };
    let _ = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(loop_.run(task, &mut world))
        .unwrap();

    let seen = w.seen.lock().unwrap().clone();
    assert!(
        seen.iter().any(|e| e.starts_with("checked:expect-42")),
        "every verdict must be observable: {seen:?}"
    );
    assert!(
        seen.iter().any(|e| e.starts_with("breach:")),
        "the breach must reach hooks, not just the outcome: {seen:?}"
    );
    let _ = std::fs::remove_dir_all(&d);
}

#[test]
fn a_goal_runs_to_completion_one_phase_at_a_time() {
    // The shape a host should be able to write: a while-let, and nothing else.
    let d = workspace("goal-drive");
    let store = harness_loop::GoalStore::open(d.join(".goals")).unwrap();
    let mut goal = harness_loop::Goal::new("g", "make answer.txt say 42", 1)
        .with_phases(["write the answer"])
        .with_verify("answer.txt matches contract.txt");
    store.save(&goal).unwrap();

    let loop_ = AgentLoop::new(Scripted::new(vec![("answer.txt", "42")]))
        .with_tool(Arc::new(WriteFile))
        .with_acceptance_set(vec![Arc::new(ExpectFortyTwo {
            contract: PathBuf::from("contract.txt"),
        })]);
    let mut world = default_world(&d);
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();

    let step = rt
        .block_on(loop_.run_goal(&mut goal, &store, &mut world, 2))
        .unwrap();
    let (_, receipt) = step.expect("one phase was owed");
    assert!(receipt.passed, "{}", receipt.summary());
    assert!(goal.complete());

    // And it is complete *on disk*, not just in memory.
    assert!(store.load("g").unwrap().complete());
    // A second call has nothing to do.
    assert!(
        rt.block_on(loop_.run_goal(&mut goal, &store, &mut world, 3))
            .unwrap()
            .is_none()
    );
    let _ = std::fs::remove_dir_all(&d);
}

#[test]
fn a_failed_phase_is_persisted_with_its_reason() {
    // The branch hosts forget. If this is not saved, resume retries the phase
    // as though nothing had been learned — and the run you most wanted a record
    // of is the one with no record.
    let d = workspace("goal-fail");
    let store = harness_loop::GoalStore::open(d.join(".goals")).unwrap();
    let mut goal =
        harness_loop::Goal::new("g", "make answer.txt say 42", 1).with_phases(["write the answer"]);

    let loop_ = AgentLoop::new(Scripted::new(vec![("answer.txt", "nope")]))
        .with_tool(Arc::new(WriteFile))
        .with_acceptance_set(vec![Arc::new(ExpectFortyTwo {
            contract: PathBuf::from("contract.txt"),
        })])
        .with_acceptance_retries(0);
    let mut world = default_world(&d);
    let (_, receipt) = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(loop_.run_goal(&mut goal, &store, &mut world, 2))
        .unwrap()
        .expect("a phase was owed");

    assert!(!receipt.passed);
    let reloaded = store.load("g").unwrap();
    assert!(!reloaded.complete());
    let (_, phase) = reloaded.current().unwrap();
    assert_eq!(phase.status, harness_loop::PhaseStatus::Failed);
    assert!(phase.note.contains("FAILED"), "{}", phase.note);
    // And the next brief tells the model what went wrong last time.
    assert!(reloaded.brief().contains("did not hold"));
    let _ = std::fs::remove_dir_all(&d);
}

#[test]
fn the_receipt_names_the_run_the_loop_actually_did() {
    // run_receipted exists so the task and model cannot drift from the run
    // they describe — the failure mode of making the caller restate them.
    let d = workspace("receipted");
    let loop_ = AgentLoop::new(Scripted::new(vec![("answer.txt", "42")]))
        .with_tool(Arc::new(WriteFile))
        .with_acceptance_set(vec![Arc::new(ExpectFortyTwo {
            contract: PathBuf::from("contract.txt"),
        })]);
    let mut world = default_world(&d);
    let task = Task {
        description: "the exact thing that was asked".into(),
        source: None,
        deadline: None,
    };
    let (_, r) = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(loop_.run_receipted(task, &mut world, 1730000000000))
        .unwrap();

    assert_eq!(r.task, "the exact thing that was asked");
    assert_eq!(r.model, "scripted");
    assert_eq!(r.finished_ms, 1730000000000);
    assert!(r.intact() && r.passed);
    assert_eq!(
        r.contract.entries.len(),
        1,
        "the sealed contract is recorded"
    );
    let _ = std::fs::remove_dir_all(&d);
}