Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,46 @@

## Unreleased

- Section 18.1 gates durable writes behind the watermark. Durable
incorporation of stream text, into conversation history, a session store,
or any record a later evaluation or run can read, follows the same rule as
emission: a rune is eligible for a durable write when the watermark covers
it, under every safety level, and a terminal deny or a failing settlement
forbids persisting the withheld or uncleared runes. This restates the
AGENT-HOOKS-0.1 section 6.1 discard obligation at the granularity the
profile evaluates. The released prefix is already part of the caller
visible record and may stay durable alongside the refusal that followed
it. Mirrored as a module doc obligation on `StreamSession`.
- Section 18.1 defines the caller as any consumer outside the enforcement
boundary. A host registered observer, a callback, a preview channel, or a
sink fed from the raw accumulation is a caller, and withheld runes must
not be delivered to one. The profile holds no text, so nothing structural
separates the accumulation from a channel wired ahead of the release
decision; the stated obligation is the whole of the protection.
- Section 18.1 states that the attempt boundary is not a clearance boundary.
A track resuming at an offset above zero retains the last `L - 1` runes
the earlier attempt delivered and includes them in the value it evaluates

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should say "delivered so far, across all attempts"? 419 has it right and this doesn't. An attempt that delivers fewer than L - 1 runes leaves the next one short.

forbidden at [1,10) of zforbiddenzzz. Attempt 2 delivers one rune, attempt 3 retains e instead of forbidde, clears, and the caller assembles the term.

Same wording in the PR body and mediation.rs:418.

near the boundary, since a term can straddle the attempts and no value
drawn from the new attempt alone can contain it. A host that no longer
holds that tail must not resume the track under the profile. A mediation
test covers a term straddling the resume boundary, with the host that
dropped the tail as the negative control.
- Section 18.1 states the released text identity obligation. The runes the
host releases are rune identical to the runes the recorded outcomes were
evaluated against; a host side rewrite after clearance invalidates the
clearance, and altered text belongs on the whole snapshot path or in a new
session. Added to the `StreamSession` module doc obligations.
- Section 18.1 requires settlement of every opened session, including one
the host abandons on disconnect, cancellation, or replacement by a retry.
An abandoned session settles like any other, so uncleared residue is
recorded rather than lost with the dropped session.
- Section 18.1 states the interaction with the `output` point, which section
18 keeps on the whole snapshot path in every case. A host adopting the
profile for caller facing egress receives that verdict after runes have
reached the caller, so a deny there cannot recall them; the host records
it and does not present the stream as settled clean, per the
AGENT-HOOKS-0.1 section 6.1a record and close shape.

## 0.4.0-alpha.2

- Section 18's requirement that a host assemble streamed model output before
Expand Down
23 changes: 22 additions & 1 deletion engine/src/stream_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
//! runes. So the host declares the range it evaluated, and the session tracks
//! only what that clears.
//!
//! This leaves the host owing three obligations that a session cannot check
//! This leaves the host owing five obligations that a session cannot check
//! for it:
//!
//! 1. The text evaluated for a span covers at least that span, and reaches
Expand All @@ -44,6 +44,27 @@
//! `evaluate_only` evaluation. A cleared span releases text, which
//! specification section 20 forbids presenting an `evaluate_only` result as
//! doing. No verdict carries the mode, so nothing here can check it.
//! 4. The runes released are rune identical to the runes the recorded
//! outcomes were evaluated against. A clearance vouches for the text the
//! task saw, so a host side rewrite after clearance, the host's own post
//! processing included, invalidates the clearance it would ride on. Text
//! the host must alter belongs on the whole snapshot path, or in a new
//! session over the altered value. A session holds no text, so it cannot
//! compare the two.
//! 5. Durable incorporation of stream text waits for the same watermark that
//! gates emission. A rune becomes eligible for a durable write when the
//! watermark covers it, under every safety level; on a terminal deny or a
//! failing settlement the withheld and uncleared runes are not persisted,
//! per AGENT-HOOKS-0.1 section 6.1. The released prefix is already part
//! of the caller visible record and may stay durable alongside the
//! refusal that followed it.
//!
//! The caller these obligations withhold from is any consumer outside the
//! enforcement boundary: an observer, a callback, a preview channel, a sink
//! fed from the raw accumulation. Specification section 18.1 states the
//! full set, including that a host settles every session it opens, an
//! abandoned one included, because a dropped session leaves a trail with no
//! settlement outcome.
//!
//! One capability limit is worth stating plainly, because it is not obvious
//! from the types. A `transform` ends the session. The substitution replaces
Expand Down
112 changes: 112 additions & 0 deletions engine/tests/stream_session_mediation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,115 @@ fn a_transform_verdict_records_the_host_obligation_to_substitute() {
"settlement must tell the host the stream was not verbatim"
);
}

/// A session resuming attempt 2 of a response stream whose first attempt
/// delivered runes `[0, resume_at)` before it was abandoned.
fn resumed_session(resume_at: u32) -> StreamSession {
StreamSession::new(StreamSessionConfig {
safety_level: SafetyLevel::Blocking,
request_start_rune_offset: 0,
response_start_rune_offset: resume_at,
request_tasks: Vec::new(),
response_tasks: vec!["harm".to_string()],
})
.expect("config is valid")
}

/// Drive a resumed attempt over `payloads`, evaluating each span over the
/// accumulated attempt text prefixed by `retained`, and return what the
/// retry released. `retained` is the tail of the earlier attempt's delivered
/// text that the host kept across the boundary; passing an empty string is
/// the host that dropped it.
fn drive_resumed(
runtime: &Runtime,
session: &mut StreamSession,
resume_at: u32,
retained: &str,
payloads: &[&str],
) -> String {
let mut attempt_text = String::new();
let mut released = String::new();
let mut cursor = resume_at;
let mut emitted = resume_at;
for payload in payloads {
attempt_text.push_str(payload);
let end = session
.observe_text(StreamSourceType::ModelGenerated, payload)
.expect("observe");
let span =
StreamSpan::new(StreamSourceType::ModelGenerated, cursor, end).expect("range is valid");
let value = format!("{retained}{attempt_text}");
let verdict = evaluate(runtime, &value);
session
.record_verdict("harm", &span, &verdict)
.expect("the outcome records, a denial included");
cursor = end;
if session.is_ended() {
break;
}
if let Some(safe) = session.advance(StreamTrack::Response) {
released.push_str(&slice_runes(
&attempt_text,
emitted - resume_at,
safe - resume_at,
));
emitted = safe;
}
}
released
}

#[test]
fn a_term_straddling_a_resume_boundary_is_caught_with_the_retained_tail() {
// Section 18.1: the attempt boundary is not a clearance boundary. A
// track resuming at an offset above zero MUST retain the last `L - 1`
// runes the earlier attempt delivered and include them in the value it
// evaluates near the boundary.
//
// Attempt 1 released `xxxxforb`, 8 runes, then was abandoned. The term
// `forbidden` is 9 runes at [4, 13): it begins inside attempt 1's
// released tail and ends inside attempt 2's first spans, so no value
// drawn from attempt 2 alone can ever contain it. With `L - 1` of 8 the
// tail the host must retain happens to be the whole of what attempt 1
// delivered.
let runtime = runtime("forbidden");
let delivered = "xxxxforb";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixture puts the term 4 runes below the boundary, so this denies with a 4 rune tail and never exercises the mandated 8. If you want it to pin the boundary, delivered = "forbidde" with payloads ["n", "yyy"] and released == "" leaks at 7 and denies at 8.

The control below might be sharper with a 7 rune tail than an empty one.

let resume_at = 8;
let mut session = resumed_session(resume_at);
let released = drive_resumed(
&runtime,
&mut session,
resume_at,
delivered,
&["idde", "nyyy"],
);
assert!(matches!(
session.end_reason(),
Some(StreamEndReason::Denied { .. })
));
// The retry released only the prefix cleared before the term completed.
assert_eq!(released, "idde");
let caller_sees = format!("{delivered}{released}");
assert!(!caller_sees.contains("forbidden"));
}

#[test]
fn a_resumed_host_that_drops_the_prior_tail_misses_the_straddling_term() {
// The negative control for the retention obligation. The same stream,
// resumed by a host that evaluates only what attempt 2 accumulated:
// every value it evaluates holds at most `iddenyyy`, so every span
// clears, the session settles clean, and the caller assembles the term
// across the attempts.
let runtime = runtime("forbidden");
let delivered = "xxxxforb";
let resume_at = 8;
let mut session = resumed_session(resume_at);
let released = drive_resumed(&runtime, &mut session, resume_at, "", &["idde", "nyyy"]);
assert_eq!(session.finish().reason, StreamEndReason::Complete);
assert_eq!(released, "iddenyyy");
let caller_sees = format!("{delivered}{released}");
assert!(
caller_sees.contains("forbidden"),
"dropping the prior attempt's tail is what the retention obligation exists to prevent"
);
}
Loading