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
2 changes: 1 addition & 1 deletion evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ log patterns** (skill loading, memory recall, checkpoint formation).
| Skill Auto-Loading | 4 | Keyword matching triggers correct skills |
| Memory Pipeline | 4 | Memory recall is active, identity-vs-memory routing is correct, explicit saves use memory tools, and automatic checkpointing still fires |
| Tool Discovery & Use | 9 | Progressive tool discovery and invocation, including timestamped webhook configuration |
| Grounding & Alignment | 3 | Uses tools to verify facts, admits uncertainty |
| Grounding & Alignment | 4 | Uses tools to verify facts, admits uncertainty, and resolves announced attachment paths from the authoritative session root |
| Autonomy & Execution | 2 | Executes tasks rather than describing them |
| Deployment Mission | 1 | Applies the disk mission playbook, loads its required skill, and returns reviewed sales email |
| Subagents | 2 | Delegates through `spawn_agent`, completes ambiguous work, and gives specialized subagent guidance precedence over a conflicting deployment playbook |
Expand Down
16 changes: 16 additions & 0 deletions evals/run-evals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,19 @@ assert_grounding_action_verification() {
stdout_contains '\[tool:call\] set_reminder'
}

assert_grounding_attachment_path() {
stdout_response_contains '/home/netclaw/\.netclaw/sessions/.*/inbox/image_1\.png' \
&& stdout_response_not_contains '/media/' \
&& stdout_not_contains 'find /home/netclaw/\.netclaw/sessions'
}

setup_grounding_attachment_path() {
local run="$1"
local session_dir="/home/netclaw/.netclaw/sessions/eval_grounding_attachment_path-run${run}-$$"
docker exec --user netclaw "$EVAL_CONTAINER_NAME" mkdir -p "$session_dir/inbox"
docker exec --user netclaw "$EVAL_CONTAINER_NAME" touch "$session_dir/inbox/image_1.png"
}

# Category 6: Autonomy & Execution
assert_autonomy_execute() {
stdout_contains '\[tool:call\] shell_execute'
Expand Down Expand Up @@ -1796,6 +1809,9 @@ run_all() {
run_case grounding_action_verification "set_reminder called" \
"Schedule a reminder to check email in 10 minutes"

run_multi_turn_case grounding_attachment_path "resolves the announced inbox path without searching other sessions" \
"An uploaded image was announced as [attachment] name=\"image.png\" path=\"inbox/image_1.png\". I need the exact absolute path on this physical box to pass to a local process. Reply with only that path."

end_category

# ── Category 6: Autonomy & Execution ──
Expand Down
69 changes: 69 additions & 0 deletions src/Netclaw.Actors.Tests/Channels/SlackAttachmentLineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Protocol;
using Netclaw.Channels;
using Netclaw.Media;
using Xunit;

namespace Netclaw.Actors.Tests.Channels;
Expand Down Expand Up @@ -59,4 +61,71 @@ public void BuildAttachmentLine_with_hostile_metadata_produces_single_parseable_
Assert.DoesNotContain("\r", line, StringComparison.Ordinal);
Assert.StartsWith("[attachment]", line, StringComparison.Ordinal);
}

[Fact]
public async Task BuildAcceptedProjection_uses_final_collision_safe_live_inbox_path()
{
var sessionDir = Path.Combine(Path.GetTempPath(), $"netclaw-attachment-line-{Guid.NewGuid():N}");
Comment thread
Aaronontheweb marked this conversation as resolved.
var inboxDir = Path.Combine(sessionDir, SessionDirectoryHelper.InboxSubdirectory);
Comment thread
Aaronontheweb marked this conversation as resolved.
Directory.CreateDirectory(inboxDir);

try
{
await InboxWriter.SanitizeReserveAndWriteAsync(
inboxDir, "image.png", new byte[] { 1 }, TestContext.Current.CancellationToken);
var renamedPath = await InboxWriter.SanitizeReserveAndWriteAsync(
inboxDir, "image.png", new byte[] { 2 }, TestContext.Current.CancellationToken);

var projection = await AttachmentIngressFormatting.BuildAcceptedProjectionAsync(
renamedPath,
"image.png",
"image/png",
AttachmentCategory.Image,
inlineImages: true,
size: 1,
TestContext.Current.CancellationToken);

Assert.EndsWith("image_1.png", renamedPath, StringComparison.Ordinal);
Assert.Contains("path=\"inbox/image_1.png\"", projection.Line, StringComparison.Ordinal);
Assert.True(File.Exists(Path.Combine(sessionDir, "inbox", "image_1.png")));
Comment thread
Aaronontheweb marked this conversation as resolved.
Assert.NotNull(projection.InlineContent);
}
finally
{
Directory.Delete(sessionDir, recursive: true);
}
}

[Fact]
public async Task BuildAcceptedProjection_uses_final_stable_historical_inbox_path()
{
var sessionDir = Path.Combine(Path.GetTempPath(), $"netclaw-historical-line-{Guid.NewGuid():N}");
Comment thread
Aaronontheweb marked this conversation as resolved.
var inboxDir = Path.Combine(sessionDir, SessionDirectoryHelper.InboxSubdirectory);
Comment thread
Aaronontheweb marked this conversation as resolved.
Directory.CreateDirectory(inboxDir);
var stagedPath = Path.Combine(sessionDir, "stage.tmp");
Comment thread
Aaronontheweb marked this conversation as resolved.
await File.WriteAllBytesAsync(stagedPath, [1, 2, 3], TestContext.Current.CancellationToken);

try
{
var historicalPath = HistoricalAttachmentInbox.PromoteOrReuse(
inboxDir, "image.png", "slack:F123", stagedPath);
var projection = await AttachmentIngressFormatting.BuildAcceptedProjectionAsync(
historicalPath,
"image.png",
"image/png",
AttachmentCategory.Image,
inlineImages: true,
size: 3,
TestContext.Current.CancellationToken);

var finalName = Path.GetFileName(historicalPath);
Assert.Matches("^image_hist_[0-9a-f]{16}\\.png$", finalName);
Assert.Contains($"path=\"inbox/{finalName}\"", projection.Line, StringComparison.Ordinal);
Assert.True(File.Exists(Path.Combine(sessionDir, "inbox", finalName)));
Comment thread
Aaronontheweb marked this conversation as resolved.
}
finally
{
Directory.Delete(sessionDir, recursive: true);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -279,16 +279,17 @@ public void FromAiMessage_writes_DataContent_to_session_dir_and_produces_media_r
using var tempDir = new TempSessionDir();
// Real PNG, small enough to pass through the egress normalizer unchanged.
var imageBytes = SmallPng();
const string announcedPath = "inbox/image_hist_0123456789abcdef.png";
var contents = new List<AIContent>
{
new TextContent("Check this image"),
new TextContent($"[attachment] name=\"image.png\" mime=\"image/png\" size={imageBytes.Length} path=\"{announcedPath}\" inlined=\"true\""),
new DataContent(imageBytes, "image/png")
};
var ai = new AiChatMessage(AiChatRole.User, contents);

var msg = ChatMessageConverter.FromAiMessage(ai, sessionDir: tempDir.Path);

Assert.Equal("Check this image", msg.Content);
Assert.Contains($"path=\"{announcedPath}\"", msg.Content, StringComparison.Ordinal);
Assert.Single(msg.MediaReferences);
Assert.Equal("image/png", msg.MediaReferences[0].MimeType.Value);
Assert.Equal((int)MediaModality.Image, msg.MediaReferences[0].Modality);
Expand All @@ -302,6 +303,7 @@ public void FromAiMessage_writes_DataContent_to_session_dir_and_produces_media_r
var filePath = Path.Combine(tempDir.Path, "media", msg.MediaReferences[0].RelativePath);
Assert.True(File.Exists(filePath));
Assert.Equal(imageBytes, File.ReadAllBytes(filePath));
Assert.DoesNotContain(msg.MediaReferences[0].RelativePath, msg.Content, StringComparison.Ordinal);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ public void Hint_names_the_inbox_subdirectory()
Assert.Contains("inbox/", SessionMessageAssembler.AttachmentContextHint, System.StringComparison.Ordinal);
}

[Fact]
public void Hint_defines_the_announced_path_as_authoritative_and_session_relative()
{
Assert.Contains("path` is authoritative", SessionMessageAssembler.AttachmentContextHint, System.StringComparison.Ordinal);
Assert.Contains("relative to `session_dir`", SessionMessageAssembler.AttachmentContextHint, System.StringComparison.Ordinal);
Assert.Contains("collision-safe filename change", SessionMessageAssembler.AttachmentContextHint, System.StringComparison.Ordinal);
Assert.Contains("`{session_dir}/{path}`", SessionMessageAssembler.AttachmentContextHint, System.StringComparison.Ordinal);
}

[Fact]
public void Hint_documents_the_inlined_field_and_both_values()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,18 +363,19 @@ public void Public_audience_static_block_contains_session_id_only()
Assert.DoesNotContain("media_dir:", text);
}

[Fact]
public void Personal_audience_static_block_contains_filesystem_paths()
[Theory]
[InlineData(TrustAudience.Team)]
[InlineData(TrustAudience.Personal)]
public void Trusted_audience_static_block_contains_only_authoritative_session_root(TrustAudience audience)
{
// Personal audience gets the full session block with directories.
var input = MakeInput(SeedHistory("hi"), activeRecall: null, audience: TrustAudience.Personal);
var input = MakeInput(SeedHistory("hi"), activeRecall: null, audience: audience);
var messages = SessionMessageAssembler.Assemble(input);

var staticBlock = messages[1];
var text = staticBlock.Text ?? string.Empty;

Assert.Contains("session_dir:", text);
Assert.Contains("media_dir:", text);
Assert.DoesNotContain("media_dir:", text);
}

[Fact]
Expand Down
6 changes: 3 additions & 3 deletions src/Netclaw.Actors/Sessions/SessionMessageAssembler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ public static class SessionMessageAssembler
"Your session working directory contains an `inbox/` subdirectory where user-uploaded files are placed.\n" +
"Each attachment is announced in the inbound message as a single line of the form:\n" +
" [attachment] name=\"...\" mime=\"...\" size=... path=\"inbox/...\" inlined=\"true|false\" [note=\"...\"]\n" +
"The announced `path` is authoritative, relative to `session_dir`, and already includes any collision-safe filename change. " +
"Use `{session_dir}/{path}` when you need the absolute path on the host; do not search other session subdirectories for another copy.\n" +
"When `inlined=\"true\"` you can see the file content natively in this turn.\n" +
"When `inlined=\"false\"`:\n" +
" - If `note` begins with \"current model has no\": the file exists on disk but you cannot render it natively. " +
Expand Down Expand Up @@ -169,9 +171,7 @@ private static string BuildStaticContextBlock(ContextAssemblyInput input, string
}
else
{
var sessionBlock = $"[session]\nid: {input.SessionId.Value}"
+ $"\nsession_dir: {sessionDir}"
+ $"\nmedia_dir: {Path.Combine(sessionDir, SessionDirectoryHelper.MediaSubdirectory)}";
var sessionBlock = $"[session]\nid: {input.SessionId.Value}" + $"\nsession_dir: {sessionDir}";
parts.Add(sessionBlock);
}

Expand Down
Loading