fix(worker): make list_tasks/batch_get_tasks lean by default

list_tasks on a list of ~100 verbosely-described tasks could return
390k+ characters in one call, blowing past the caller's token limit.
Both tools now default to lean TaskRefDto references (no
Description/Result) and take an includeDescription flag to opt back
into the full TaskDto payload — same flag-alongside-nullable-payload
idiom already used by BatchGetTaskResult/TaskConfigResult. get_task is
unchanged.
This commit is contained in:
mika kuns
2026-08-06 11:25:32 +02:00
parent 86f962ee7d
commit f8c48e2ed7
5 changed files with 151 additions and 22 deletions
+14 -2
View File
@@ -1,8 +1,8 @@
# External MCP tool surface # External MCP tool surface
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `bdee731` (2026-08-05). > Last verified against commit `86f962e` (2026-08-06).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/External` > Drift check: `git log --oneline 86f962e..HEAD -- src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md. > Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
@@ -27,6 +27,11 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key`
the resulting config so the caller can see which fields were set vs. cleared to null). the resulting config so the caller can see which fields were set vs. cleared to null).
- *Read* tools that may have nothing to return use an explicit `Found` / `Available` flag - *Read* tools that may have nothing to return use an explicit `Found` / `Available` flag
alongside the nullable payload (`TaskConfigResult`, `BatchGetTaskResult`, `TaskLogResult`). alongside the nullable payload (`TaskConfigResult`, `BatchGetTaskResult`, `TaskLogResult`).
- The same flag-alongside-nullable-payload idiom also covers "which of two shapes did you
get": `ListTasks`/`BatchGetTasks` take `includeDescription` (default `false`) and return
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
list of verbosely-described tasks from blowing past the response size limit by default.
3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so 3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` — `InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
otherwise the SDK's catch-all replaces any non-`McpException` with a generic otherwise the SDK's catch-all replaces any non-`McpException` with a generic
@@ -62,6 +67,11 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
## Per-tool behaviour worth knowing ## Per-tool behaviour worth knowing
**`ListTasks`** — `includeDescription=false` (default) returns lean `TaskRefDto` references in
`tasks` (`tasksFull` null); `includeDescription=true` returns full `TaskDto`s (incl.
Description/Result) in `tasksFull` instead (`tasks` null). Filtering by `createdBy`/`status`
happens before the lean/full projection either way.
**`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only. **`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only.
- `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the - `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the
**only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on **only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on
@@ -102,6 +112,8 @@ directory is shared with other concurrent sessions.
**Sequential**, because the scoped `DbContext` is not thread-safe. Merge/review stay **Sequential**, because the scoped `DbContext` is not thread-safe. Merge/review stay
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
failing item never aborts the rest — and rejects batches over **100 items**. failing item never aborts the rest — and rejects batches over **100 items**.
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
`BatchGetTaskResult` carries `task` (lean) or `taskFull` (full), never both.
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB. **`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
+21 -7
View File
@@ -6,7 +6,10 @@ namespace ClaudeDo.Worker.External;
public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null); public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null);
public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null);
public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error); // task is populated when found and includeDescription=false (the default, lean reference);
// taskFull is populated when found and includeDescription=true (full task incl.
// Description/Result). Both are null when found=false.
public sealed record BatchGetTaskResult(string Id, bool Found, TaskRefDto? Task, TaskDto? TaskFull, string? Error);
public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskRefDto? Task, string? Error); public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskRefDto? Task, string? Error);
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error); public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error);
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error); public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error);
@@ -30,10 +33,13 @@ public sealed class BatchMcpTools
[McpServerTool, Description( [McpServerTool, Description(
"Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " + "Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " +
"Returns one result per id: { id, found, task, error }. A missing id is found=false " + "Returns one result per id: { id, found, task, taskFull, error }. " +
"(not an error); error is only set for an unexpected failure. Max 100 ids.")] "includeDescription=false (default): found tasks come back in `task` (lean reference, no " +
"Description/Result). includeDescription=true: found tasks come back in `taskFull` (incl. " +
"Description/Result) instead. A missing id is found=false (not an error; task and taskFull both null); " +
"error is only set for an unexpected failure. Max 100 ids.")]
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks( public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
string[] taskIds, CancellationToken cancellationToken) string[] taskIds, bool includeDescription = false, CancellationToken cancellationToken = default)
{ {
EnsureWithinCap(taskIds, nameof(taskIds)); EnsureWithinCap(taskIds, nameof(taskIds));
@@ -41,18 +47,26 @@ public sealed class BatchMcpTools
foreach (var id in taskIds) foreach (var id in taskIds)
{ {
try try
{
if (includeDescription)
{ {
var task = await _svc.GetTask(id, cancellationToken); var task = await _svc.GetTask(id, cancellationToken);
results.Add(new BatchGetTaskResult(id, true, task, null)); results.Add(new BatchGetTaskResult(id, true, null, task, null));
}
else
{
var taskRef = await _svc.GetTaskRefAsync(id, cancellationToken);
results.Add(new BatchGetTaskResult(id, true, taskRef, null, null));
}
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {
results.Add(new BatchGetTaskResult(id, false, null, null)); results.Add(new BatchGetTaskResult(id, false, null, null, null));
} }
catch (Exception ex) catch (Exception ex)
{ {
results.Add(new BatchGetTaskResult(id, false, null, ex.Message)); results.Add(new BatchGetTaskResult(id, false, null, null, ex.Message));
} }
} }
return results; return results;
+30 -3
View File
@@ -47,6 +47,15 @@ public sealed record TaskRefDto(
int SortOrder, int SortOrder,
bool IsMyDay); bool IsMyDay);
// tasks is populated when includeDescription=false (the default): lean references, no
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
// Description/Result. Exactly one of the two is non-null per the includeDescription flag —
// same "flag alongside nullable payload" idiom as BatchGetTaskResult.
public sealed record ListTasksResult(
bool IncludeDescription,
IReadOnlyList<TaskRefDto>? Tasks,
IReadOnlyList<TaskDto>? TasksFull);
public sealed record WorktreeInfoDto( public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit, string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);
@@ -139,11 +148,17 @@ public sealed class ExternalMcpService
[McpServerTool, Description( [McpServerTool, Description(
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " + "List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")] "Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled. " +
public async Task<IReadOnlyList<TaskDto>> ListTasks( "includeDescription=false (default): returns lean task references in `tasks` (no Description/Result) — " +
"use this unless you actually need the description text, since a list of verbosely-described tasks can " +
"otherwise blow past the response size limit. " +
"includeDescription=true: returns full tasks (incl. Description/Result) in `tasksFull` instead; `tasks` is " +
"null in that case.")]
public async Task<ListTasksResult> ListTasks(
string listId, string listId,
string? createdBy = null, string? createdBy = null,
string? status = null, string? status = null,
bool includeDescription = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
TaskStatus? statusFilter = null; TaskStatus? statusFilter = null;
@@ -162,7 +177,10 @@ public sealed class ExternalMcpService
if (statusFilter is not null) if (statusFilter is not null)
query = query.Where(t => t.Status == statusFilter); query = query.Where(t => t.Status == statusFilter);
return query.Select(ToDto).ToList(); var filtered = query.ToList();
return includeDescription
? new ListTasksResult(true, null, filtered.Select(ToDto).ToList())
: new ListTasksResult(false, filtered.Select(ToRefDto).ToList(), null);
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -177,6 +195,15 @@ public sealed class ExternalMcpService
return ToDto(task); return ToDto(task);
} }
// Lean counterpart to GetTask, used internally by BatchGetTasks' default (includeDescription=false)
// path. Not an MCP tool itself — GetTask's own behavior stays untouched.
internal async Task<TaskRefDto> GetTaskRefAsync(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
return ToRefDto(task);
}
[McpServerTool, Description( [McpServerTool, Description(
"Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " + "Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " + "Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
+41 -3
View File
@@ -149,17 +149,55 @@ public sealed class BatchMcpToolsTests : IDisposable
var task = await SeedTaskAsync(listId); var task = await SeedTaskAsync(listId);
var sut = BuildSut(); var sut = BuildSut();
var results = await sut.BatchGetTasks(new[] { task.Id, "nope" }, CancellationToken.None); var results = await sut.BatchGetTasks(new[] { task.Id, "nope" }, cancellationToken: CancellationToken.None);
var found = results.Single(r => r.Id == task.Id); var found = results.Single(r => r.Id == task.Id);
var missing = results.Single(r => r.Id == "nope"); var missing = results.Single(r => r.Id == "nope");
Assert.True(found.Found); Assert.True(found.Found);
Assert.NotNull(found.Task); Assert.NotNull(found.Task);
Assert.Null(found.TaskFull);
Assert.False(missing.Found); Assert.False(missing.Found);
Assert.Null(missing.Task); Assert.Null(missing.Task);
Assert.Null(missing.TaskFull);
Assert.Null(missing.Error); Assert.Null(missing.Error);
} }
[Fact]
public async Task BatchGetTasks_Default_ReturnsLeanTask_NoTaskFull()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
task.Description = "a description that should not come back by default";
await _tasks.UpdateAsync(task);
var sut = BuildSut();
var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None);
var found = results.Single(r => r.Id == task.Id);
Assert.True(found.Found);
Assert.NotNull(found.Task);
Assert.Equal(task.Id, found.Task!.Id);
Assert.Null(found.TaskFull);
}
[Fact]
public async Task BatchGetTasks_IncludeDescriptionTrue_ReturnsTaskFull()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
task.Description = "the full description";
await _tasks.UpdateAsync(task);
var sut = BuildSut();
var results = await sut.BatchGetTasks(new[] { task.Id }, includeDescription: true, cancellationToken: CancellationToken.None);
var found = results.Single(r => r.Id == task.Id);
Assert.True(found.Found);
Assert.Null(found.Task);
Assert.NotNull(found.TaskFull);
Assert.Equal("the full description", found.TaskFull!.Description);
}
[Fact] [Fact]
public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted() public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted()
{ {
@@ -212,7 +250,7 @@ public sealed class BatchMcpToolsTests : IDisposable
{ {
var sut = BuildSut(); var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.BatchGetTasks(Array.Empty<string>(), CancellationToken.None)); () => sut.BatchGetTasks(Array.Empty<string>(), cancellationToken: CancellationToken.None));
} }
[Fact] [Fact]
@@ -222,7 +260,7 @@ public sealed class BatchMcpToolsTests : IDisposable
var ids = Enumerable.Range(0, 101).Select(i => i.ToString()).ToArray(); var ids = Enumerable.Range(0, 101).Select(i => i.ToString()).ToArray();
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.BatchGetTasks(ids, CancellationToken.None)); () => sut.BatchGetTasks(ids, cancellationToken: CancellationToken.None));
Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase);
} }
} }
@@ -947,10 +947,11 @@ public sealed class ExternalMcpServiceTests : IDisposable
await SeedTaskAsync(listId, "idle", TaskStatus.Idle); await SeedTaskAsync(listId, "idle", TaskStatus.Idle);
var sut = NewService(); var sut = NewService();
var result = await sut.ListTasks(listId, null, "WaitingForReview", CancellationToken.None); var result = await sut.ListTasks(listId, null, "WaitingForReview", cancellationToken: CancellationToken.None);
Assert.Single(result); Assert.NotNull(result.Tasks);
Assert.Equal("WaitingForReview", result[0].Status); Assert.Single(result.Tasks!);
Assert.Equal("WaitingForReview", result.Tasks![0].Status);
} }
[Fact] [Fact]
@@ -961,10 +962,47 @@ public sealed class ExternalMcpServiceTests : IDisposable
await SeedTaskAsync(listId, "done", TaskStatus.Done); await SeedTaskAsync(listId, "done", TaskStatus.Done);
var sut = NewService(); var sut = NewService();
var result = await sut.ListTasks(listId, null, "WaitingForChildren", CancellationToken.None); var result = await sut.ListTasks(listId, null, "WaitingForChildren", cancellationToken: CancellationToken.None);
Assert.Single(result); Assert.NotNull(result.Tasks);
Assert.Equal("WaitingForChildren", result[0].Status); Assert.Single(result.Tasks!);
Assert.Equal("WaitingForChildren", result.Tasks![0].Status);
}
[Fact]
public async Task ListTasks_Default_ReturnsLeanReferences_NoDescriptionOrTasksFull()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, "with desc");
task.Description = "a long description that should not come back by default";
await _tasks.UpdateAsync(task);
var sut = NewService();
var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None);
Assert.False(result.IncludeDescription);
Assert.NotNull(result.Tasks);
Assert.Null(result.TasksFull);
Assert.Single(result.Tasks!);
Assert.Equal(task.Id, result.Tasks![0].Id);
}
[Fact]
public async Task ListTasks_IncludeDescriptionTrue_ReturnsFullTasks()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, "with desc");
task.Description = "the full description";
await _tasks.UpdateAsync(task);
var sut = NewService();
var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);
Assert.True(result.IncludeDescription);
Assert.Null(result.Tasks);
Assert.NotNull(result.TasksFull);
Assert.Single(result.TasksFull!);
Assert.Equal("the full description", result.TasksFull![0].Description);
} }
// ── MergeTask allowWaitingForReview ─────────────────────────────────────── // ── MergeTask allowWaitingForReview ───────────────────────────────────────