Skip to content
Draft
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
11 changes: 10 additions & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
version: "2.74.4"
version: "2.74.5"
---

# Netclaw Operations
Expand Down Expand Up @@ -188,6 +188,15 @@ starts a fresh cycle window; compaction alone does not.
If a text-only response contains tool calls, Netclaw rejects those calls and reports a provider failure.
This failure does not prove that the turn exhausted its tool budget.

Cycle disposition diagnostics use one structured event for each evaluated batch.
The event reports the decision, cycle counts, batch size, and dispatch state.
`Execute` with `Dispatched=true` means that the batch reached the execution pipeline.
This state does not prove that a tool succeeded.
Approval can still wait or deny the batch.
`Correct` and `Stop` always use `Dispatched=false`.
The event contains no tool payload or identity data.
These events are process-level diagnostics, not per-session `session.log` entries.

## Large tool output

Tool output is bounded to a small inline budget
Expand Down
58 changes: 58 additions & 0 deletions src/Netclaw.Actors.Tests/Sessions/ActiveToolBatchTrackerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// -----------------------------------------------------------------------
// <copyright file="ActiveToolBatchTrackerTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Microsoft.Extensions.AI;
using Netclaw.Actors.Sessions;
using Netclaw.Actors.Sessions.Handlers;
using Xunit;

namespace Netclaw.Actors.Tests.Sessions;

public sealed class ActiveToolBatchTrackerTests
{
[Fact]
public void Start_StoresRequestedBatchSizeAndClearsDispatchState()
{
var tracker = new ActiveToolBatchTracker();
tracker.Start(
[
new FunctionCallContent("call-1", "probe"),
new FunctionCallContent("call-2", "probe")
],
preparedCycleBatch: null);

Assert.Equal(2, tracker.BatchSize);
Assert.False(tracker.HasReachedDispatch);

tracker.MarkDispatched();
Assert.True(tracker.HasReachedDispatch);

tracker.Start([new FunctionCallContent("call-3", "probe")], preparedCycleBatch: null);

Assert.Equal(1, tracker.BatchSize);
Assert.False(tracker.HasReachedDispatch);
}

[Fact]
public void Clear_RemovesBatchAndDispatchState()
{
var tracker = new ActiveToolBatchTracker();
tracker.Start([new FunctionCallContent("call-1", "probe")], preparedCycleBatch: null);
tracker.MarkDispatched();

tracker.Clear();

Assert.Equal(0, tracker.BatchSize);
Assert.False(tracker.HasReachedDispatch);
}

[Fact]
public void MarkDispatched_WithoutActiveBatchFailsLoudly()
{
var tracker = new ActiveToolBatchTracker();

Assert.Throws<InvalidOperationException>(tracker.MarkDispatched);
}
}
53 changes: 53 additions & 0 deletions src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// </copyright>
// -----------------------------------------------------------------------
using Akka.Actor;
using Akka.Event;
using Akka.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -73,6 +74,8 @@ public async Task Passivated_session_resumes_tool_batch_when_approval_arrives()
{
const string callId = "call-shell-1";
_toolExecutor.GatedTools.Add("shell_execute");
var diagnostics = CreateTestProbe();
Sys.EventStream.Subscribe(diagnostics, typeof(LogEvent));

_fakeChatClient.ToolCallsOnFirstCall =
[
Expand Down Expand Up @@ -153,11 +156,61 @@ await subscriberB.ExpectMsgAsync<TextOutput>(
[authorizationAttemptId, authorizationAttemptId],
_toolExecutor.AuthorizationAttempts.ToArray());

var dispositions = new List<LogEvent>();
dispositions.Add(await diagnostics.FishForMessageAsync<LogEvent>(
IsCycleDisposition,
TimeSpan.FromSeconds(5),
cancellationToken: TestContext.Current.CancellationToken));
var disposition = Assert.Single(dispositions);
var properties = Assert.IsAssignableFrom<LogMessage>(disposition.Message).GetProperties();
Assert.Equal(
["BatchSize", "Decision", "Dispatched", "HistoryCount", "IterationCount", "Period", "Repetitions"],
properties.Keys.Order(StringComparer.Ordinal));
Assert.Equal("Execute", GetProperty(disposition, "Decision"));
Assert.Equal(0, GetIntProperty(disposition, "HistoryCount"));
Assert.Equal(0, GetIntProperty(disposition, "IterationCount"));
Assert.Equal(0, GetIntProperty(disposition, "Period"));
Assert.Equal(0, GetIntProperty(disposition, "Repetitions"));
Assert.Equal(1, GetIntProperty(disposition, "BatchSize"));
Assert.True(GetDispatched(disposition));

var trailingDiagnostics = new List<LogEvent>();
await foreach (var diagnostic in diagnostics.ReceiveWhileAsync<LogEvent>(
new Predicate<LogEvent>(AssertNoCycleDisposition),
max: TimeSpan.FromSeconds(1),
idle: TimeSpan.FromMilliseconds(100),
msgs: int.MaxValue,
shouldIgnoreOtherMessageTypes: true,
cancellationToken: TestContext.Current.CancellationToken))
{
trailingDiagnostics.Add(diagnostic);
}

Assert.DoesNotContain(trailingDiagnostics, IsCycleDisposition);

// No duplicate approval prompt was emitted for the re-driven call.
await subscriberB.ExpectNoMsgAsync(
TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
}

private static bool IsCycleDisposition(LogEvent logEvent)
=> logEvent.Message.ToString()!.StartsWith("tool_cycle_disposition", StringComparison.Ordinal);

private static bool AssertNoCycleDisposition(LogEvent logEvent)
{
Assert.False(IsCycleDisposition(logEvent));
return true;
}

private static string GetProperty(LogEvent logEvent, string name)
=> Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]?.ToString()!;

private static int GetIntProperty(LogEvent logEvent, string name)
=> Assert.IsType<int>(Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]);

private static bool GetDispatched(LogEvent logEvent)
=> Assert.IsType<bool>(Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()["Dispatched"]);

[Fact]
public async Task Idle_passivation_proceeds_with_pending_approval_and_response_resumes()
{
Expand Down
72 changes: 65 additions & 7 deletions src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ await sessionManager.Ask<CommandAck>(new SendUserMessage
public async Task Exact_tool_cycle_gets_one_correction_then_stops_without_execution(bool violatesTextOnly)
{
var diagnostics = CreateTestProbe();
Sys.EventStream.Subscribe(diagnostics, typeof(Warning));
Sys.EventStream.Subscribe(diagnostics, typeof(LogEvent));
_fakeChatClient.ToolCallsOnFirstCall =
[
new FunctionCallContent(
Expand Down Expand Up @@ -885,12 +885,13 @@ await sessionManager.Ask<CommandAck>(new SendUserMessage
Content = "Repeat the same search."
}, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken);

var toolCallOutputs = new List<ToolCallOutput>();
var results = new List<ToolResultOutput>();
for (var i = 0; i < 3; i++)
{
await subscriber.ExpectMsgAsync<ToolCallOutput>(
toolCallOutputs.Add(await subscriber.ExpectMsgAsync<ToolCallOutput>(
TimeSpan.FromSeconds(3),
cancellationToken: TestContext.Current.CancellationToken);
cancellationToken: TestContext.Current.CancellationToken));
results.Add(await subscriber.ExpectMsgAsync<ToolResultOutput>(
TimeSpan.FromSeconds(3),
cancellationToken: TestContext.Current.CancellationToken));
Expand All @@ -913,24 +914,81 @@ await subscriber.ExpectMsgAsync<TurnCompleted>(
TimeSpan.FromSeconds(3),
cancellationToken: TestContext.Current.CancellationToken);

Assert.Equal(3, toolCallOutputs.Count);
Assert.Equal("same result", results[0].Result);
Assert.Equal("same result", results[1].Result);
Assert.Contains("repeated action-and-outcome cycle", results[2].Result, StringComparison.Ordinal);
Assert.Equal(2, _fakeToolExecutor.CallCount);
Assert.Equal(5, _fakeChatClient.CallCount);
for (var i = 0; i < 2; i++)
var dispositions = new List<LogEvent>();
for (var i = 0; i < 4; i++)
{
var diagnostic = await diagnostics.FishForMessageAsync<Warning>(
warning => warning.Message.ToString()!.StartsWith("Tool cycle decision", StringComparison.Ordinal),
var diagnostic = await diagnostics.FishForMessageAsync<LogEvent>(
logEvent => logEvent.Message.ToString()!.StartsWith("tool_cycle_disposition", StringComparison.Ordinal),
TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken);
dispositions.Add(diagnostic);
}

Assert.Equal(
["Execute", "Execute", "Correct", "Stop"],
dispositions.Select(static evt => GetProperty(evt, "Decision")).ToArray());
Assert.Equal([0, 1, 2, 2], dispositions.Select(static evt => GetIntProperty(evt, "HistoryCount")).ToArray());
Assert.Equal([0, 1, 2, 3], dispositions.Select(static evt => GetIntProperty(evt, "IterationCount")).ToArray());
Assert.Equal([1, 1, 1, 1], dispositions.Select(static evt => GetIntProperty(evt, "BatchSize")).ToArray());
Assert.Equal([true, true, false, false], dispositions.Select(static evt => GetBoolProperty(evt, "Dispatched")).ToArray());
Assert.Equal([0, 0, 1, 0], dispositions.Select(static evt => GetIntProperty(evt, "Period")).ToArray());
Assert.Equal([0, 0, 2, 0], dispositions.Select(static evt => GetIntProperty(evt, "Repetitions")).ToArray());
Assert.False(GetBoolProperty(dispositions[2], "Dispatched"));
Assert.Equal(2, _fakeToolExecutor.CallCount);

foreach (var diagnostic in dispositions)
{
Assert.Equal(typeof(TurnStateTracker), diagnostic.LogClass);
Assert.DoesNotContain(sessionId.Value, diagnostic.LogSource, StringComparison.Ordinal);
Assert.Equal($"TurnStateTracker (akka://{Sys.Name})", diagnostic.LogSource);
var message = diagnostic.Message.ToString()!;
Assert.DoesNotContain("search_tools", message, StringComparison.Ordinal);
Assert.DoesNotContain("browser", message, StringComparison.Ordinal);
Assert.DoesNotContain("same result", message, StringComparison.Ordinal);
var properties = Assert.IsAssignableFrom<LogMessage>(diagnostic.Message).GetProperties();
Assert.Equal(["DecisionKind", "Period", "Repetitions"], properties.Keys.Order(StringComparer.Ordinal));
Assert.Equal(
["BatchSize", "Decision", "Dispatched", "HistoryCount", "IterationCount", "Period", "Repetitions"],
properties.Keys.Order(StringComparer.Ordinal));
}

var trailingDiagnostics = new List<LogEvent>();
await foreach (var diagnostic in diagnostics.ReceiveWhileAsync<LogEvent>(
new Predicate<LogEvent>(AssertNoCycleDisposition),
max: TimeSpan.FromSeconds(1),
idle: TimeSpan.FromMilliseconds(100),
msgs: int.MaxValue,
shouldIgnoreOtherMessageTypes: true,
cancellationToken: TestContext.Current.CancellationToken))
{
trailingDiagnostics.Add(diagnostic);
}

Assert.DoesNotContain(trailingDiagnostics, IsCycleDisposition);
}

private static bool IsCycleDisposition(LogEvent logEvent)
=> logEvent.Message.ToString()!.StartsWith("tool_cycle_disposition", StringComparison.Ordinal);

private static bool AssertNoCycleDisposition(LogEvent logEvent)
{
Assert.False(IsCycleDisposition(logEvent));
return true;
}

private static string GetProperty(LogEvent logEvent, string name)
=> Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]?.ToString()!;

private static int GetIntProperty(LogEvent logEvent, string name)
=> Assert.IsType<int>(Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]);

private static bool GetBoolProperty(LogEvent logEvent, string name)
=> Assert.IsType<bool>(Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]);

[Fact]
public async Task Parallel_cycle_correction_preserves_every_call_result_pair()
{
Expand Down
28 changes: 28 additions & 0 deletions src/Netclaw.Actors.Tests/Sessions/TurnStateTrackerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,34 @@ public void NewTurnClearsCycleState_ButAnUnrelatedBoundaryDoesNot()
tracker.EvaluateBeforeDispatch(batch.Action).Kind);
}

[Fact]
public void CycleDecision_SnapshotsCountsBeforeCandidateEvaluation()
{
var tracker = new TurnStateTracker();
var batch = Prepare(Call("call-1", "sample/read"));
var completed = Complete(
batch,
("call-1", ToolInvocationOutcomeCategory.Success, "same"));

var initial = tracker.EvaluateBeforeDispatch(batch.Action);
Assert.Equal(0, initial.HistoryCount);
Assert.Equal(0, initial.IterationCount);

tracker.ObserveCompleted(completed);
tracker.RecordToolCompletion(resultCount: 1, maxToolIterationsPerTurn: 30);
tracker.ObserveCompleted(completed);
tracker.RecordToolCompletion(resultCount: 1, maxToolIterationsPerTurn: 30);

var corrected = tracker.EvaluateBeforeDispatch(batch.Action);
Assert.Equal(ToolCycleDecisionKind.Correct, corrected.Kind);
Assert.Equal(2, corrected.HistoryCount);
Assert.Equal(2, corrected.IterationCount);

tracker.RecordToolCompletion(resultCount: 1, maxToolIterationsPerTurn: 30);
Assert.Equal(2, corrected.HistoryCount);
Assert.Equal(2, corrected.IterationCount);
}

[Fact]
public void CompletedCycleHistory_KeepsOnlySixIterations()
{
Expand Down
63 changes: 58 additions & 5 deletions src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1685,7 +1685,7 @@ public async Task Max_iterations_forces_text_response()
public async Task Exact_tool_cycle_gets_one_correction_then_stops()
{
var diagnostics = CreateTestProbe();
Sys.EventStream.Subscribe(diagnostics, typeof(Warning));
Sys.EventStream.Subscribe(diagnostics, typeof(LogEvent));
var executionCount = 0;
var fakeTool = new FakeNetclawTool(
"mutate_state",
Expand Down Expand Up @@ -1739,19 +1739,72 @@ public async Task Exact_tool_cycle_gets_one_correction_then_stops()
Assert.Equal(2, toolResults.Count(static text => text == "loop result"));
Assert.Single(toolResults, static text =>
text.Contains("repeated action-and-outcome cycle", StringComparison.Ordinal));
for (var i = 0; i < 2; i++)
var dispositions = new List<LogEvent>();
for (var i = 0; i < 4; i++)
{
var diagnostic = await diagnostics.FishForMessageAsync<Warning>(
warning => warning.Message.ToString()!.StartsWith("Subagent tool cycle decision", StringComparison.Ordinal),
var diagnostic = await diagnostics.FishForMessageAsync<LogEvent>(
logEvent => logEvent.Message.ToString()!.StartsWith("tool_cycle_disposition", StringComparison.Ordinal),
TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken);
dispositions.Add(diagnostic);
}

Assert.Equal(
["Execute", "Execute", "Correct", "Stop"],
dispositions.Select(static evt => GetProperty(evt, "Decision")).ToArray());
Assert.Equal([0, 1, 2, 2], dispositions.Select(static evt => GetIntProperty(evt, "HistoryCount")).ToArray());
Assert.Equal([0, 1, 2, 3], dispositions.Select(static evt => GetIntProperty(evt, "IterationCount")).ToArray());
Assert.Equal([1, 1, 1, 1], dispositions.Select(static evt => GetIntProperty(evt, "BatchSize")).ToArray());
Assert.Equal([true, true, false, false], dispositions.Select(static evt => GetBoolProperty(evt, "Dispatched")).ToArray());
Assert.Equal([0, 0, 1, 0], dispositions.Select(static evt => GetIntProperty(evt, "Period")).ToArray());
Assert.Equal([0, 0, 2, 0], dispositions.Select(static evt => GetIntProperty(evt, "Repetitions")).ToArray());

foreach (var diagnostic in dispositions)
{
Assert.Equal(typeof(Netclaw.Actors.Sessions.Handlers.TurnStateTracker), diagnostic.LogClass);
Assert.Equal($"TurnStateTracker (akka://{Sys.Name})", diagnostic.LogSource);
Assert.DoesNotContain(agent.Path.Name, diagnostic.LogSource, StringComparison.Ordinal);
var message = diagnostic.Message.ToString()!;
Assert.DoesNotContain("mutate_state", message, StringComparison.Ordinal);
Assert.DoesNotContain("loop result", message, StringComparison.Ordinal);
var properties = Assert.IsAssignableFrom<LogMessage>(diagnostic.Message).GetProperties();
Assert.Equal(["DecisionKind", "Period", "Repetitions"], properties.Keys.Order(StringComparer.Ordinal));
Assert.Equal(
["BatchSize", "Decision", "Dispatched", "HistoryCount", "IterationCount", "Period", "Repetitions"],
properties.Keys.Order(StringComparer.Ordinal));
}

var trailingDiagnostics = new List<LogEvent>();
await foreach (var diagnostic in diagnostics.ReceiveWhileAsync<LogEvent>(
new Predicate<LogEvent>(AssertNoCycleDisposition),
max: TimeSpan.FromSeconds(1),
idle: TimeSpan.FromMilliseconds(100),
msgs: int.MaxValue,
shouldIgnoreOtherMessageTypes: true,
cancellationToken: TestContext.Current.CancellationToken))
{
trailingDiagnostics.Add(diagnostic);
}

Assert.DoesNotContain(trailingDiagnostics, IsCycleDisposition);
}

private static bool IsCycleDisposition(LogEvent logEvent)
=> logEvent.Message.ToString()!.StartsWith("tool_cycle_disposition", StringComparison.Ordinal);

private static bool AssertNoCycleDisposition(LogEvent logEvent)
{
Assert.False(IsCycleDisposition(logEvent));
return true;
}

private static string GetProperty(LogEvent logEvent, string name)
=> Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]?.ToString()!;

private static int GetIntProperty(LogEvent logEvent, string name)
=> Assert.IsType<int>(Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]);

private static bool GetBoolProperty(LogEvent logEvent, string name)
=> Assert.IsType<bool>(Assert.IsAssignableFrom<LogMessage>(logEvent.Message).GetProperties()[name]);

[Fact]
public async Task Timeout_returns_failure()
{
Expand Down
Loading
Loading