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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ User-visible changes to the spec and SDKs. Versioning rules:

## Unreleased

- **SDKs: `record_host_failure` — a host projection failure is no
longer recordless.** When the host's own to-wire projection fails
before a valid `AgentContext` exists (e.g. a tool-call argument
getter throws at the chat seam), the emitter can now synthesize and
deliver the fail-closed record itself: a `deny
host_error:context_invalid` in the §10.3 rejection shape (null
identities under the declared provider, payload-free type-name/path
detail, envelope facts the host still knows). All five SDKs
(`record_host_failure` / `recordHostFailure` / `RecordHostFailure`);
§10.3 gains the "Host projection failure" host obligation and §11
lists it as a synthesis site. No new reserved reason and no record
shape change — additive.
- **Microsoft Agent Framework: full §13.1 conformance claim.** The MAF row upgrades from partial cross-validation (33/47) to a conformance claim — 47/47 applicable vectors pass on the declared surface (`tool_seam_host_error: terminate`, `buffered_output: true` → 4 capability-gated skips); report and harness updated in `conformance/claims/maf/`.
- **CTK: declared `tool_seam_host_error` posture (§13.1).** A harness declares `continue` (default) or `terminate`, and `expect.run_outcome_by_posture` resolves the 13 tool-seam `host_error:*` vectors to the single outcome that declared surface must produce — the §6.2 terminate clause is now claimable (#68).
- **CTK: AH-CTK-100 asserts §6.1 substance, not transcript cosmetics.** New `context_must_contain`/`context_must_not_contain` interception assertions pin non-incorporation of the denied tool result and the deny surfacing to the model in some form, leaving message layout and payload shape to the host (#69).
Expand Down
68 changes: 68 additions & 0 deletions sdk/dotnet/src/AgentHooks/InterceptionEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,74 @@ public async ValueTask<InterceptionRecord> EmitUncheckedAsync(
_mode == EnforcementMode.Enforce ? "enforce" : "evaluate_only",
options.ToJsonString(Compact));
var record = RecordFromCore((JsonObject)JsonNode.Parse(recordJson)!);
return Deliver(record);
}

/// <summary>§10.3/§11 host projection failure: synthesize and deliver
/// the fail-closed record for an emission whose <see cref="AgentContext"/>
/// the host could not construct at all — its own projection to the
/// wire failed before anything existed to emit (e.g. a tool-call
/// argument property getter threw during to-wire conversion at the
/// chat seam). Without this the host can only fail the action closed
/// <b>recordless</b>; with it the trail stays complete under host-side
/// faults.
///
/// <para>The record is the §10.3 rejection shape: the payload-free
/// projection of a <c>deny host_error:context_invalid</c> carrying
/// <paramref name="detail"/> (payload-free: an exception <b>type
/// name</b> or a path, never the content that failed to project —
/// §14 data minimization) as its message; <c>null</c> identities
/// under the declared provider; <c>decided_by: null</c>; no
/// per-interceptor summaries (no interceptor ran). The optional
/// parameters carry the envelope facts the host still knows;
/// <paramref name="sequence"/> SHOULD be the number the failed
/// emission would have carried (consume the next one from the
/// context source so records stay totally ordered); absent members
/// record the §10.3 unknown values (<c>""</c>/<c>-1</c>). The record
/// takes the next slot in the record stream (sink, then buffer) like
/// any emission. In <c>enforce</c> mode the host MUST still fail the
/// action closed; in <c>evaluate_only</c> the record documents the
/// host fault without implying enforcement (§8).</para></summary>
public InterceptionRecord RecordHostFailure(
InterceptionPoint point,
string? detail = null,
string? sessionId = null,
long? sequence = null,
string? timestamp = null)
{
// Deliberately partial basis: only the envelope facts the host
// still knows. It never passes §4 validation (`spec` is absent),
// so the core's finalize always yields the §10.3 rejection shape
// — null identities under the declared provider — and keeps the
// synthesized `context_invalid` deny (with the host's detail)
// instead of substituting its own.
var basis = new JsonObject { ["interception_point"] = point.ToWireName() };
if (sessionId is not null) basis["session"] = new JsonObject { ["id"] = sessionId };
if (sequence is { } seq) basis["sequence"] = seq;
if (timestamp is not null) basis["timestamp"] = timestamp;
var options = new JsonObject
{
["input_identity"] = null,
["identity_provider"] = _identity.Name,
["enforced_identity"] = null,
["decided_by"] = null,
["composition"] = _composition.ToWire(),
["verdicts"] = new JsonArray(),
["fold_truncated"] = null,
["resolved_by"] = null,
["interceptors_registered"] = _interceptors.Count,
};
var recordJson = Native.Finalize(
basis.ToJsonString(Compact),
Verdict.FromHostError(HostError.ContextInvalid, detail).ToWire().ToJsonString(Compact),
_mode == EnforcementMode.Enforce ? "enforce" : "evaluate_only",
options.ToJsonString(Compact));
return Deliver(RecordFromCore((JsonObject)JsonNode.Parse(recordJson)!));
}

/// <summary>Deliver a record to the sink and the bounded buffer (§10.3).</summary>
private InterceptionRecord Deliver(InterceptionRecord record)
{
if (_recordSink is { } sink)
{
// Audit delivery must not take down the control plane (§10.3).
Expand Down
83 changes: 83 additions & 0 deletions sdk/dotnet/test/AgentHooks.Tests/RecordSemanticsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,86 @@ public async Task NamesAndCountOnRecord()
Assert.Null(r.Verdicts[1].Name);
}
}

public class HostFailureTests
{
private sealed class Allow : IInterceptor
{
public ValueTask<Verdict> InterceptAsync(AgentContext ctx, CancellationToken ct = default)
=> ValueTask.FromResult(Verdict.Allow);
}

[Fact]
public void RecordHostFailureSynthesizesRejectionShape()
{
// §10.3 host projection failure: the host could not construct a
// context at all; the synthesized record is the rejection shape
// with the host's envelope facts.
var em = new InterceptionEmitter();
em.Register(new Allow());
var r = em.RecordHostFailure(
InterceptionPoint.PreToolCall,
detail: "InvalidOperationException",
sessionId: "s",
sequence: 7,
timestamp: "2026-01-01T00:00:00Z");
Assert.False(r.Proceeds);
Assert.Equal(InterceptionPoint.PreToolCall, r.InterceptionPoint);
Assert.Equal("host_error:context_invalid", r.Verdict.Reason);
Assert.Equal("InvalidOperationException", r.Verdict.Message);
// §10.3 rejection shape: null identities under the declared
// provider, nothing dispatched.
Assert.Equal("jcs-sha256", r.IdentityProvider);
Assert.Null(r.InputIdentity);
Assert.Null(r.EnforcedIdentity);
Assert.Null(r.DecidedBy);
Assert.Empty(r.Verdicts);
Assert.Equal(1, r.InterceptorsRegistered);
// Envelope facts the host supplied.
Assert.Equal("s", r.SessionId);
Assert.Equal(7, r.Sequence);
Assert.Equal("2026-01-01T00:00:00Z", r.Timestamp);
// The record entered the emitter's stream like any emission.
Assert.Single(em.Records);
}

[Fact]
public void RecordHostFailureDefaultsAreTheUnknownValues()
{
var em = new InterceptionEmitter();
var r = em.RecordHostFailure(InterceptionPoint.Output);
Assert.Equal("", r.SessionId);
Assert.Equal(-1, r.Sequence);
Assert.Null(r.Timestamp);
Assert.Null(r.Verdict.Message);
Assert.Equal(0, r.InterceptorsRegistered);
}

[Fact]
public void RecordHostFailureRecordsInEvaluateOnlyAndHitsSink()
{
// §8: synthesis still records in evaluate_only — records are the
// point — and the mode member keeps the record from implying a
// block happened.
var em = new InterceptionEmitter(EnforcementMode.EvaluateOnly);
var seen = new List<InterceptionRecord>();
em.SetRecordSink(seen.Add);
var r = em.RecordHostFailure(InterceptionPoint.PreToolCall, detail: "TypeError");
Assert.Equal(EnforcementMode.EvaluateOnly, r.Mode);
Assert.Equal("host_error:context_invalid", r.Verdict.Reason);
Assert.Equal([r], seen);
}

[Fact]
public void RecordHostFailureDetailTruncatedByProjection()
{
// §10.3: the synthesized verdict crosses the same payload-free
// projection as every combined verdict.
var em = new InterceptionEmitter();
var r = em.RecordHostFailure(
InterceptionPoint.PreToolCall, detail: new string('x', 300));
Assert.NotNull(r.Verdict.Message);
Assert.EndsWith("…", r.Verdict.Message);
Assert.True(System.Text.Encoding.UTF8.GetByteCount(r.Verdict.Message!) <= 256 + 3);
}
}
99 changes: 98 additions & 1 deletion sdk/go/agenthooks/emitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,103 @@ func (e *InterceptionEmitter) EmitUnchecked(ctx context.Context, actx AgentConte
if err := json.Unmarshal([]byte(recJSON), &rec); err != nil {
return InterceptionRecord{}, err
}
return e.deliver(rec), nil
}

// HostFailure carries the envelope facts for RecordHostFailure: what
// the host still knows about an emission whose context it could not
// construct (§10.3 "Host projection failure"). Everything is optional —
// an absent member records the §10.3 unknown value (session_id: "",
// sequence: -1, timestamp absent).
type HostFailure struct {
// Detail is the payload-free failure detail — an error TYPE NAME
// or a path, never the content that failed to project (§14 data
// minimization). Recorded as the verdict message (truncated by the
// §10.3 projection). Empty records nothing.
Detail string
// SessionID is session.id of the failed emission, when the host
// knows it. Empty records the unknown value.
SessionID string
// Sequence is the number the failed emission would have carried.
// The host SHOULD consume the next number from its context source
// so records stay totally ordered within the session (§10.3). nil
// records -1.
Sequence *int64
// Timestamp is the RFC 3339 event time, when the host has one.
Timestamp string
}

// RecordHostFailure synthesizes and delivers the §10.3/§11 fail-closed
// record for an emission whose AgentContext the host could not
// construct at all — its own projection to the wire failed before
// anything existed to Emit (e.g. a tool-call argument failed to-wire
// conversion at the chat seam). Without this the host can only fail
// the action closed recordless; with it the trail stays complete under
// host-side faults.
//
// The record is the §10.3 rejection shape: the payload-free projection
// of a deny host_error:context_invalid carrying failure.Detail as its
// message; null identities under the declared provider; decided_by
// null; no per-interceptor summaries (no interceptor ran); envelope
// members from HostFailure, with the §10.3 unknown values (""/-1)
// where absent. It takes the next slot in the record stream (sink,
// then buffer) like any emission. In enforce mode the host MUST still
// fail the action closed; in evaluate_only the record documents the
// host fault without implying enforcement (§8). A non-nil error is an
// infrastructure failure only (JSON marshalling or core invocation).
func (e *InterceptionEmitter) RecordHostFailure(point InterceptionPoint, failure HostFailure) (InterceptionRecord, error) {
// Deliberately partial basis: only the envelope facts the host
// still knows. It never passes §4 validation (spec is absent), so
// the core's finalize always yields the §10.3 rejection shape —
// null identities under the declared provider — and keeps the
// synthesized context_invalid deny (with the host's detail)
// instead of substituting its own.
basis := map[string]any{"interception_point": string(point)}
if failure.SessionID != "" {
basis["session"] = map[string]any{"id": failure.SessionID}
}
if failure.Sequence != nil {
basis["sequence"] = *failure.Sequence
}
if failure.Timestamp != "" {
basis["timestamp"] = failure.Timestamp
}
opts := map[string]any{
"input_identity": nil,
"identity_provider": e.identity.name(),
"enforced_identity": nil,
"decided_by": nil,
"composition": e.composition,
"verdicts": []VerdictSummary{},
"fold_truncated": nil,
"resolved_by": nil,
"interceptors_registered": len(e.interceptors),
}
basisJSON, err := json.Marshal(basis)
if err != nil {
return InterceptionRecord{}, err
}
verdictJSON, err := json.Marshal(HostErrorVerdict(ErrContextInvalid, failure.Detail))
if err != nil {
return InterceptionRecord{}, err
}
optsJSON, err := json.Marshal(opts)
if err != nil {
return InterceptionRecord{}, err
}
recJSON, err := nativeFinalize(string(basisJSON), string(verdictJSON), string(e.mode), string(optsJSON))
if err != nil {
return InterceptionRecord{}, err
}
var rec InterceptionRecord
if err := json.Unmarshal([]byte(recJSON), &rec); err != nil {
return InterceptionRecord{}, err
}
return e.deliver(rec), nil
}

// deliver hands a record to the sink and the bounded buffer (§10.3).
func (e *InterceptionEmitter) deliver(rec InterceptionRecord) InterceptionRecord {
if e.recordSink != nil {
// Audit delivery must not take down the control plane (§10.3).
func() {
Expand All @@ -453,7 +550,7 @@ func (e *InterceptionEmitter) EmitUnchecked(ctx context.Context, actx AgentConte
}
e.records = append(e.records, rec)
e.mu.Unlock()
return rec, nil
return rec
}

// -----------------------------------------------------------------------------
Expand Down
Loading
Loading