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
31 changes: 31 additions & 0 deletions docs/attachments-and-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,34 @@ EveMessageResponse resumed = await session.SendAsync(

Input responses are retried for the short propagation window where an accepted
durable session is not yet visible to the delivery route.

## Classify a request

eve stamps every input request with a framework-owned discriminator, projected as
`EveInputRequest.Kind`. Route on it instead of inferring intent from `Display`,
`Options`, or the tool name inside `Action`: a session-limit prompt can arrive
with a confirmation hint, two options, and a tool name, yet it is not an
approve/deny tool prompt.

```csharp
string answer = request.Kind switch
{
EveInputRequestKind.ToolApproval => "approve",
EveInputRequestKind.SessionLimit => "continue",
EveInputRequestKind.Question => ChooseAnswer(request),
_ => throw new NotSupportedException(
$"Unhandled eve input request kind '{request.RawKind}'."),
};
```

`Kind` reports `EveInputRequestKind.Unknown` in two cases, and `RawKind`
distinguishes them:

| `Kind` | `RawKind` | Meaning |
|---|---|---|
| `Question`, `ToolApproval`, `SessionLimit` | matching wire value | A modelled request kind |
| `Unknown` | the wire value | A newer eve emitted a kind this package does not model |
| `Unknown` | `null` | The server predates the discriminator, such as eve `0.27.6` |

A `kind` that is present but not a string is a malformed request and throws
`EveProtocolException` rather than being reported as a legacy server.
12 changes: 12 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,15 @@ header, so bounded reads against that baseline fail with `EveProtocolException`
instead of silently degrading to a live follow. The compatibility probe asserts
both halves of that contract and switches to verifying a real bounded read once
the pinned server reports the header.

## Input request kinds

eve stamps each human-input request with a framework-owned `kind` of `question`,
`tool-approval`, or `session-limit`. `EveInputRequest.Kind` projects it and
`EveInputRequest.RawKind` preserves the wire value, so an unmodelled future kind
stays inspectable instead of being misclassified from its option shape.

eve `0.27.6` predates the discriminator and omits it, which reports
`EveInputRequestKind.Unknown` with a `null` raw value. The compatibility probe
drives a real approval-gated tool against the pinned fixture and asserts that
behavior end to end.
166 changes: 166 additions & 0 deletions src/NexusLabs.Eve.Tests/EveInputRequestKindTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System.Net;
using System.Text;

namespace NexusLabs.Eve.Tests;

public sealed class EveInputRequestKindTests
{
[Test]
[Arguments("question", EveInputRequestKind.Question)]
[Arguments("tool-approval", EveInputRequestKind.ToolApproval)]
[Arguments("session-limit", EveInputRequestKind.SessionLimit)]
public async Task InputRequest_ProjectsFrameworkOwnedKind(
string wireKind,
EveInputRequestKind expectedKind,
CancellationToken cancellationToken)
{
EveTurnOutcome outcome = await CollectOutcomeAsync(
InputRequestedEvent($",\"kind\":\"{wireKind}\""),
cancellationToken);

await Assert.That(outcome.InputRequests.Count).IsEqualTo(1);
await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(expectedKind);
await Assert.That(outcome.InputRequests[0].RawKind).IsEqualTo(wireKind);
}

[Test]
public async Task SessionLimitRequest_IsNotClassifiedAsToolApprovalByItsShape(
CancellationToken cancellationToken)
{
EveTurnOutcome outcome = await CollectOutcomeAsync(
"""{"type":"input.requested","data":{"requests":[{"requestId":"limit_1","prompt":"The session reached its step limit.","kind":"session-limit","display":"confirmation","options":[{"id":"continue","label":"Continue"},{"id":"stop","label":"Stop"}],"action":{"kind":"tool-call","toolName":"bash"}}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}""",
cancellationToken);

await Assert.That(outcome.InputRequests.Count).IsEqualTo(1);
EveInputRequest request = outcome.InputRequests[0];
await Assert.That(request.Kind).IsEqualTo(EveInputRequestKind.SessionLimit);
await Assert.That(request.Display)
.IsEqualTo("confirmation")
.Because("A confirmation display hint no longer implies a tool approval.");
await Assert.That(request.Options.Count).IsEqualTo(2);
await Assert.That(request.Options[0].Id).IsEqualTo("continue");
await Assert.That(request.Options[1].Id).IsEqualTo("stop");
await Assert.That(request.Action.GetProperty("toolName").GetString())
.IsEqualTo("bash")
.Because("An accompanying tool name no longer implies a tool approval.");
}

[Test]
public async Task QuestionRequest_IsNotClassifiedByItsTwoOptionShape(
CancellationToken cancellationToken)
{
EveTurnOutcome outcome = await CollectOutcomeAsync(
"""{"type":"input.requested","data":{"requests":[{"requestId":"question_1","prompt":"Ship it?","kind":"question","display":"confirmation","allowFreeform":true,"options":[{"id":"approve","label":"Yes"},{"id":"deny","label":"No"}],"action":{"kind":"tool-call"}}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}""",
cancellationToken);

await Assert.That(outcome.InputRequests.Count).IsEqualTo(1);
EveInputRequest request = outcome.InputRequests[0];
await Assert.That(request.Kind)
.IsEqualTo(EveInputRequestKind.Question)
.Because("Approve/deny options with a confirmation hint are not always an approval.");
await Assert.That(request.RawKind).IsEqualTo("question");
await Assert.That(request.AllowFreeform.GetValueOrDefault())
.IsTrue()
.Because("The stream reported allowFreeform.");
await Assert.That(request.Options.Count).IsEqualTo(2);
}

[Test]
public async Task UnrecognizedKind_StaysInspectableWithoutMisclassification(
CancellationToken cancellationToken)
{
EveTurnOutcome outcome = await CollectOutcomeAsync(
InputRequestedEvent(",\"kind\":\"escalation\""),
cancellationToken);

await Assert.That(outcome.InputRequests.Count).IsEqualTo(1);
await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(EveInputRequestKind.Unknown);
await Assert.That(outcome.InputRequests[0].RawKind)
.IsEqualTo("escalation")
.Because("A future discriminator must remain readable on the wire value.");
}

[Test]
public async Task AbsentKind_IsReportedAsALegacyServerRatherThanAnUnknownValue(
CancellationToken cancellationToken)
{
EveTurnOutcome outcome = await CollectOutcomeAsync(
InputRequestedEvent(string.Empty),
cancellationToken);

await Assert.That(outcome.InputRequests.Count).IsEqualTo(1);
await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(EveInputRequestKind.Unknown);
await Assert.That(outcome.InputRequests[0].RawKind)
.IsNull()
.Because("eve versions before the discriminator omit it entirely.");
await Assert.That(outcome.InputRequests[0].Action.GetProperty("kind").GetString())
.IsEqualTo("tool-call")
.Because("The action's own kind must not be read as the request discriminator.");
await Assert.That(outcome.InputRequests[0].RequestId).IsEqualTo("request_1");
}

[Test]
public async Task NonStringKind_FailsInsteadOfImpersonatingALegacyServer(
CancellationToken cancellationToken)
{
await Assert.That(async () => await CollectOutcomeAsync(
InputRequestedEvent(",\"kind\":7"),
cancellationToken))
.Throws<EveProtocolException>();
}

[Test]
public async Task MultipleRequests_KeepTheirOwnKinds(CancellationToken cancellationToken)
{
EveTurnOutcome outcome = await CollectOutcomeAsync(
"""{"type":"input.requested","data":{"requests":[{"requestId":"approval_1","prompt":"Run bash?","kind":"tool-approval","display":"confirmation","options":[{"id":"approve","label":"Yes"}],"action":{"kind":"tool-call","toolName":"bash"}},{"requestId":"question_1","prompt":"Which environment?","kind":"question","display":"select","options":[{"id":"prod","label":"Production"}],"action":{"kind":"tool-call"}}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}""",
cancellationToken);

await Assert.That(outcome.InputRequests.Count).IsEqualTo(2);
await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(EveInputRequestKind.ToolApproval);
await Assert.That(outcome.InputRequests[0].RequestId).IsEqualTo("approval_1");
await Assert.That(outcome.InputRequests[1].Kind).IsEqualTo(EveInputRequestKind.Question);
await Assert.That(outcome.InputRequests[1].RequestId).IsEqualTo("question_1");
}

private static string InputRequestedEvent(string kindProperty) =>
"""{"type":"input.requested","data":{"requests":[{"requestId":"request_1","prompt":"Continue?","display":"confirmation","options":[{"id":"approve","label":"Approve"}],"action":{"kind":"tool-call"}"""
+ kindProperty
+ """}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}""";

private static async Task<EveTurnOutcome> CollectOutcomeAsync(
string inputRequestedEvent,
CancellationToken cancellationToken)
{
using RecordingHttpMessageHandler handler = new();
using HttpMessageInvoker transport = new(handler, false);
handler.Enqueue(static (_, _) => Task.FromResult(AcceptedResponse()));
handler.Enqueue((_, _) => Task.FromResult(StreamResponse(
inputRequestedEvent,
"""{"type":"session.waiting","data":{"continuationToken":"eve:next","wait":"human-input"}}""")));
EveSession session = new EveClient(
transport,
new EveClientOptions("https://agent.example.com")).CreateSession();

EveMessageResponse response = await session.SendAsync("Continue", cancellationToken);
return await response.GetOutcomeAsync(cancellationToken);
}

private static HttpResponseMessage AcceptedResponse() =>
new(HttpStatusCode.Accepted)
{
Content = new StringContent(
"""{"ok":true,"sessionId":"session_1","continuationToken":"eve:accepted"}""",
Encoding.UTF8,
"application/json"),
};

private static HttpResponseMessage StreamResponse(params string[] events) =>
new(HttpStatusCode.OK)
{
Content = new StringContent(
$"{string.Join('\n', events)}\n",
Encoding.UTF8,
EveProtocol.MessageStreamContentType),
};
}
24 changes: 24 additions & 0 deletions src/NexusLabs.Eve/EveInputRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ public sealed record EveInputRequest
internal EveInputRequest(
string requestId,
string prompt,
EveInputRequestKind kind,
string? rawKind,
string? display,
bool? allowFreeform,
IReadOnlyList<EveInputOption> options,
JsonElement action)
{
RequestId = requestId;
Prompt = prompt;
Kind = kind;
RawKind = rawKind;
Display = display;
AllowFreeform = allowFreeform;
Options = options;
Expand All @@ -33,6 +37,26 @@ internal EveInputRequest(
/// </summary>
public string Prompt { get; }

/// <summary>
/// Gets the framework-owned request source used to route, render, and answer this request.
/// </summary>
/// <remarks>
/// Prefer this discriminator over inferring intent from <see cref="Display"/>,
/// <see cref="Options"/>, or the tool name in <see cref="Action"/>. It reports
/// <see cref="EveInputRequestKind.Unknown"/> when the server sends no discriminator or one this
/// package does not model; <see cref="RawKind"/> then carries the wire value.
/// </remarks>
public EveInputRequestKind Kind { get; }

/// <summary>
/// Gets the discriminator exactly as the server sent it, or <see langword="null"/> when it sent none.
/// </summary>
/// <remarks>
/// eve versions before the discriminator was introduced omit it entirely, so a
/// <see langword="null"/> value is a legacy server rather than an unrecognized kind.
/// </remarks>
public string? RawKind { get; }

/// <summary>
/// Gets the optional rendering hint, such as <c>confirmation</c>, <c>select</c>, or <c>text</c>.
/// </summary>
Expand Down
41 changes: 41 additions & 0 deletions src/NexusLabs.Eve/EveInputRequestKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace NexusLabs.Eve;

/// <summary>
/// Identifies the framework-owned source of an eve human-input request.
/// </summary>
/// <remarks>
/// eve stamps this discriminator on every input request so consumers can route, render, and answer
/// a request without inferring its purpose from option shapes, display hints, or tool names.
/// </remarks>
public enum EveInputRequestKind
{
/// <summary>
/// The server did not send a recognized discriminator.
/// </summary>
/// <remarks>
/// This covers both an eve version that predates the discriminator and a future value this
/// package does not model. Read <see cref="EveInputRequest.RawKind"/> to tell them apart:
/// it is <see langword="null"/> when the server sent nothing and carries the wire value
/// otherwise.
/// </remarks>
Unknown = 0,

/// <summary>
/// The agent asked the user a question.
/// </summary>
Question,

/// <summary>
/// The agent needs approval before running a tool.
/// </summary>
ToolApproval,

/// <summary>
/// The session reached a configured limit and needs a decision before continuing.
/// </summary>
/// <remarks>
/// Options such as <c>continue</c> and <c>stop</c> belong to this kind. They are not an
/// approve/deny tool prompt even when they arrive with a confirmation display hint.
/// </remarks>
SessionLimit,
}
30 changes: 30 additions & 0 deletions src/NexusLabs.Eve/EveMessageResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ private static void AddInputRequests(
JsonElement request = requests[requestIndex];
string requestId = RequireString(request, "requestId");
string prompt = RequireString(request, "prompt");
string? rawKind = ReadInputRequestKind(request);
string? display = OptionalString(request, "display");
bool? allowFreeform = OptionalBoolean(request, "allowFreeform");
JsonElement action = request.TryGetProperty("action", out JsonElement actionValue)
Expand Down Expand Up @@ -168,13 +169,42 @@ private static void AddInputRequests(
inputRequests.Add(new EveInputRequest(
requestId,
prompt,
ResolveInputRequestKind(rawKind),
rawKind,
display,
allowFreeform,
options,
action));
}
}

// An absent discriminator is an eve version that predates it; a present non-string value is a
// malformed request that must not be reported as a legacy server.
private static string? ReadInputRequestKind(JsonElement request)
{
if (!request.TryGetProperty("kind", out JsonElement kind))
{
return null;
}

if (kind.ValueKind != JsonValueKind.String || kind.GetString() is not string value)
{
throw new EveProtocolException(
"An eve input request kind must be a string.");
}

return value;
}

private static EveInputRequestKind ResolveInputRequestKind(string? rawKind) =>
rawKind switch
{
"question" => EveInputRequestKind.Question,
"tool-approval" => EveInputRequestKind.ToolApproval,
"session-limit" => EveInputRequestKind.SessionLimit,
_ => EveInputRequestKind.Unknown,
};

private static string RequireString(JsonElement parent, string propertyName)
{
if (!parent.TryGetProperty(propertyName, out JsonElement value)
Expand Down
Loading