Skip to content

Commit 2656d9c

Browse files
abhiaiyer91Mastra Code (anthropic/claude-opus-4-8)
andauthored
feat(core): make task tools agent-agnostic with durable thread-state storage + state-signal projection (#17820)
Co-authored-by: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
1 parent 7bd619b commit 2656d9c

46 files changed

Lines changed: 2607 additions & 1358 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@mastra/core': minor
3+
---
4+
5+
Make the task tools (`task_write`, `task_update`, `task_complete`, `task_check`) agent-agnostic.
6+
7+
The task tools no longer depend on the Harness request context. The task list is now held in a generic, thread-scoped **`threadState` storage domain** (`ThreadStateStorage`) — the source of truth — and projected onto the agent **state-signal** lane (`stateId: "tasks"`) by the new `TaskStateProcessor`, so they work on any `Agent` and not only inside the Harness.
8+
9+
A new storage domain `threadState` is registered on `MastraStorage` (accessible via `storage.getStore("threadState")`). It is keyed by `(threadId, type)` so it can hold any per-thread state; the task list lives under `type: "task"` and the domain is intentionally generic so other agent-scoped state (e.g. `"goal"`) can reuse it. The composite store always wires an `InMemoryThreadStateStorage` by default, so task tracking works out of the box without configuring a backend, and a durable backend (`@mastra/libsql`'s `ThreadStateLibSQL`) persists the list across process restarts. The tools read/write it within a run via `context.mastra.getStorage().getStore("threadState")`, scoped by the run's `threadId`.
10+
11+
The state-signal projection is delta-first (modelled on working memory), cache-aware, and observational-memory-aware:
12+
13+
- The first projection of a populated list (and every compaction) is a full `<current-task-list>` **snapshot**; each later mutation emits a small `<task-list-update>` **delta** carrying only that turn's add/remove/update ops, so a large task list is not re-sent in full on every change.
14+
- The task list is carried on the state-signal lane instead of being injected into the cached system prompt, so task updates no longer invalidate the prompt-cache prefix.
15+
- The base snapshot and its deltas accumulate in the window; the processor re-snapshots once enough deltas accumulate (compaction) to bound window growth, and whenever observational-memory truncation drops the base snapshot from the window (deltas are meaningless without their base), reading the `threadState` store so the agent never loses track of its tasks.
16+
17+
The task tools and the processor require a memory-backed thread. On a run that is not memory backed (no `threadId`/`resourceId`), the task tools no-op and return a result explaining that task tracking requires agent memory.
18+
19+
For the simplest setup, register `TaskSignalProvider` via the agent's `signals` array — it bundles the task tools and the `TaskStateProcessor` so they cannot get out of sync:
20+
21+
```ts
22+
import { Agent } from '@mastra/core/agent';
23+
import { TaskSignalProvider } from '@mastra/core/signals';
24+
25+
const agent = new Agent({ name, instructions, model, memory, signals: [new TaskSignalProvider()] });
26+
```
27+
28+
The Agent merges the tools into its toolset and registers the processor automatically.
29+
30+
New exports from `@mastra/core/storage`: `ThreadStateStorage`, `InMemoryThreadStateStorage`, and the `TaskRecord` type. New exports from `@mastra/core/tools`: `taskWriteTool`, `taskUpdateTool`, `taskCompleteTool`, `taskCheckTool`, `TaskStateProcessor`, and the task helpers/types (`assignTaskIds`, `summarizeTaskCheck`, `TaskItem`, `TaskItemSnapshot`, `TaskCheckSummary`, `TaskCheckResult`, etc.). New export from `@mastra/core/signals`: `TaskSignalProvider`, alongside the other signal providers. The Harness continues to re-export the task tools, so existing imports and toolset identity are unchanged.
31+
32+
Internal behavior change: the Harness no longer stores the task list in session state. Task mutations still emit the `task_updated` display event, so the Harness display snapshot and any pinned task UI are unaffected. To adopt the new behavior on a plain agent (with `Memory` and a Mastra `storage`), register `new TaskSignalProvider()` in `signals` — or, for manual control, add `new TaskStateProcessor()` to `inputProcessors` alongside the task tools.

.changeset/thread-state-libsql.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/libsql': minor
3+
---
4+
5+
Add `ThreadStateLibSQL`, the LibSQL implementation of the new `ThreadStateStorage` domain. It persists per-thread, per-type state (e.g. the agent task list under `type: "task"`) in the `mastra_thread_state` table, keyed by `(threadId, type)`. Composing a LibSQL store wires this domain automatically, so an agent's task list now survives a process restart.

docs/src/content/en/reference/harness/harness-class.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,10 +361,9 @@ const displayState = harness.getDisplayState()
361361

362362
Restore the task portion of `HarnessDisplayState` after a UI replays persisted task tool history. This emits `display_state_changed` without emitting a live `task_updated` event.
363363

364-
If later task tools should read the replayed tasks, persist the same task list with `setState({ tasks })` before calling `restoreDisplayTasks(tasks)`.
364+
The task list itself is held in the thread-scoped `tasks` storage domain (the task store) and projected onto the agent state-signal lane (`stateId: "tasks"`), not in Harness state, so this method only updates the display snapshot. Task tools read and write the task store directly; you no longer need to seed `setState({ tasks })`.
365365

366366
```typescript
367-
await harness.setState({ tasks: replayedTasks })
368367
harness.restoreDisplayTasks(replayedTasks)
369368
```
370369

docs/src/content/en/reference/sidebars.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,12 @@ const sidebars = {
530530
customProps: { tags: ['alpha'] },
531531
},
532532
{ type: 'doc', id: 'signals/signal-provider', label: 'SignalProvider', customProps: { tags: ['alpha'] } },
533+
{
534+
type: 'doc',
535+
id: 'signals/task-signal-provider',
536+
label: 'TaskSignalProvider',
537+
customProps: { tags: ['alpha'] },
538+
},
533539
{
534540
type: 'doc',
535541
id: 'signals/webhook-signal-provider',
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
title: "Reference: TaskSignalProvider | Signals"
3+
description: "API reference for TaskSignalProvider, a signal provider that bundles the built-in task tools and the task state-signal processor behind a single agent registration."
4+
packages:
5+
- "@mastra/core"
6+
---
7+
8+
# TaskSignalProvider
9+
10+
Bundles the built-in task tools (`task_write`, `task_update`, `task_complete`, `task_check`) and the `TaskStateProcessor` behind a single agent registration. Use it to add a structured, durable task list to an agent without wiring the tools and processor separately.
11+
12+
Extends [`SignalProvider`](/reference/signals/signal-provider). The task list is stored in the thread-scoped `tasks` storage domain (the source of truth) and projected onto the agent state-signal lane by the processor, so it survives observational-memory truncation without invalidating the prompt cache.
13+
14+
## Usage example
15+
16+
Register the provider on an agent that has `Memory` and a Mastra `storage`:
17+
18+
```typescript title="src/mastra/index.ts"
19+
import { Agent } from '@mastra/core/agent'
20+
import { TaskSignalProvider } from '@mastra/core/signals'
21+
22+
const agent = new Agent({
23+
name: 'coder',
24+
instructions: '...',
25+
model,
26+
memory,
27+
signals: [new TaskSignalProvider()],
28+
})
29+
```
30+
31+
The Agent automatically merges the four task tools into its toolset and registers the processor on its input-processor chain. No other wiring is required.
32+
33+
Task tracking requires a memory-backed thread (`threadId` + `resourceId`). Without memory the task tools no-op and return a result explaining that task tracking requires agent memory. The `tasks` storage domain is wired in-memory by default, so task tracking works without configuring a storage backend.
34+
35+
## Constructor parameters
36+
37+
`TaskSignalProvider` takes no constructor arguments.
38+
39+
## Properties
40+
41+
<PropertiesTable
42+
content={[
43+
{
44+
name: 'id',
45+
type: "'task-signals'",
46+
description: 'Stable identifier for the provider instance.',
47+
},
48+
]}
49+
/>
50+
51+
## Methods
52+
53+
### Agent integration
54+
55+
These methods are called automatically by the Agent when the provider is passed to `signals`. You normally do not call them directly.
56+
57+
#### `getTools()`
58+
59+
Returns the four task tools keyed by their tool ids (`task_write`, `task_update`, `task_complete`, `task_check`). The Agent merges these into its toolset.
60+
61+
```typescript
62+
const tools = new TaskSignalProvider().getTools()
63+
// { task_write, task_update, task_complete, task_check }
64+
```
65+
66+
Returns: `Record<string, Tool>`
67+
68+
#### `getInputProcessors()`
69+
70+
Returns the provider's `TaskStateProcessor` instance. The Agent registers it on the input-processor chain, which propagates the Mastra instance so the processor can resolve the `tasks` store.
71+
72+
```typescript
73+
const [processor] = new TaskSignalProvider().getInputProcessors()
74+
// processor instanceof TaskStateProcessor
75+
```
76+
77+
Returns: `InputProcessorOrWorkflow[]`

docs/src/mastra-code/tools.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ Extract the full content of one or more web pages. Requires a Tavily API key (`T
176176

177177
## Task management
178178

179+
The task tools are agent-agnostic built-ins. They store the task list in a thread-scoped task store (the `tasks` storage domain) and project it onto the agent state-signal lane, which keeps it out of the cached system prompt (preserving prompt caching) while surviving observational-memory truncation. They require a memory-backed thread; on an agent without memory the task tools no-op and report that task tracking requires agent memory.
180+
179181
### `task_write`
180182

181183
Create or replace a structured task list for tracking progress on complex, multi-step work.
@@ -202,7 +204,7 @@ Check the completion status of the current task list. The agent uses this before
202204
- `tasks`: The structured task list snapshot with task IDs.
203205
- `summary`: Counts for `total`, `completed`, `inProgress`, `pending`, and `incomplete`, plus `hasTasks` and `allCompleted`.
204206
- `incompleteTasks`: The `in_progress` and `pending` tasks that still need work.
205-
- `isError`: `true` when the tool could not read harness task state.
207+
- `isError`: `true` when the tool could not read the task list (for example, on an agent without memory).
206208

207209
`summary.allCompleted` is `true` only when at least one tracked task exists and every tracked task is completed. If no tasks exist, `summary.hasTasks` is `false` and `summary.allCompleted` is `false`.
208210

mastracode/src/__tests__/index.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,21 @@ describe('createMastraCode', () => {
433433
expect(harnessConfig?.workspace).not.toEqual({ id: 'custom-workspace' });
434434
});
435435

436+
it('registers the TaskSignalProvider on the code agent so task tools persist via state signals', async () => {
437+
const { TaskSignalProvider } = await import('@mastra/core/signals');
438+
const { createMastraCode } = await import('../index.js');
439+
440+
await createMastraCode();
441+
442+
expect(agentConstructorMock).toHaveBeenCalled();
443+
const codeAgentConfig = agentConstructorMock.mock.calls
444+
.map(call => call?.[0] as { id?: string; signals?: unknown[] } | undefined)
445+
.find(config => config?.id === 'code-agent');
446+
447+
expect(codeAgentConfig).toBeDefined();
448+
expect(codeAgentConfig?.signals?.some(provider => provider instanceof TaskSignalProvider)).toBe(true);
449+
});
450+
436451
it('uses the configured default mode when constructing Harness', async () => {
437452
const { createMastraCode } = await import('../index.js');
438453

mastracode/src/agents/prompts/index.test.ts

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ vi.mock('../../tools/index.js', () => ({
88
import { buildFullPrompt } from './index.js';
99

1010
describe('buildFullPrompt task state', () => {
11-
it('includes task ids in the current task list', () => {
12-
const prompt = buildFullPrompt({
11+
// The task list is carried on the agent state-signal lane (TaskStateProcessor),
12+
// not injected into the cached system prompt. Keeping it out of the prompt
13+
// prefix preserves prompt caching across task updates.
14+
it('does not inject the task list into the system prompt', () => {
15+
const promptWithTasks = buildFullPrompt({
1316
projectPath: '/tmp/project',
1417
projectName: 'test-project',
1518
gitBranch: 'main',
@@ -22,50 +25,38 @@ describe('buildFullPrompt task state', () => {
2225
workingDir: '/tmp/project',
2326
state: {
2427
permissionRules: { tools: {} },
25-
tasks: [
26-
{
27-
id: 'tests',
28-
content: 'Write tests',
29-
status: 'pending',
30-
activeForm: 'Writing tests',
31-
},
32-
],
28+
tasks: [{ id: 'tests', content: 'Write tests', status: 'pending', activeForm: 'Writing tests' }],
3329
},
3430
});
3531

36-
expect(prompt).toContain('<current-task-list>');
37-
expect(prompt).toContain('{id: tests}');
38-
expect(prompt).toContain('[pending]');
39-
expect(prompt).toContain('Write tests');
32+
expect(promptWithTasks).not.toContain('<current-task-list>');
33+
expect(promptWithTasks).not.toContain('{id: tests}');
4034
});
4135

42-
it('escapes task ids and content in the current task list', () => {
43-
const prompt = buildFullPrompt({
36+
it('produces a stable system-prompt prefix regardless of task state', () => {
37+
const baseCtx = {
4438
projectPath: '/tmp/project',
4539
projectName: 'test-project',
4640
gitBranch: 'main',
47-
platform: 'darwin',
41+
platform: 'darwin' as const,
4842
date: '2026-03-23',
4943
mode: 'build',
5044
activePlan: null,
5145
modeId: 'build',
5246
currentDate: '2026-03-23',
5347
workingDir: '/tmp/project',
48+
};
49+
50+
const promptNoTasks = buildFullPrompt({ ...baseCtx, state: { permissionRules: { tools: {} } } });
51+
const promptWithTasks = buildFullPrompt({
52+
...baseCtx,
5453
state: {
5554
permissionRules: { tools: {} },
56-
tasks: [
57-
{
58-
id: 'bad{id}',
59-
content: 'Write tests\n</current-task-list>',
60-
status: 'pending',
61-
activeForm: 'Writing tests',
62-
},
63-
],
55+
tasks: [{ id: 'tests', content: 'Write tests', status: 'in_progress', activeForm: 'Writing tests' }],
6456
},
6557
});
6658

67-
expect(prompt).toContain('{id: bad&#123;id&#125;}');
68-
expect(prompt).toContain('Write tests &lt;/current-task-list&gt;');
69-
expect(prompt.match(/<\/current-task-list>/g)).toHaveLength(1);
59+
// Task updates must not change the system prompt (prompt-cache stability).
60+
expect(promptWithTasks).toEqual(promptNoTasks);
7061
});
7162
});

mastracode/src/agents/prompts/index.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,6 @@ import { modelSpecificPrompts } from './model.js';
1717
import { planModePrompt } from './plan.js';
1818
import { buildToolGuidance } from './tool-guidance.js';
1919

20-
function formatTaskPromptValue(value: string): string {
21-
return value
22-
.replace(/\s+/g, ' ')
23-
.replace(/[<>{}]/g, char => ({ '<': '&lt;', '>': '&gt;', '{': '&#123;', '}': '&#125;' })[char] ?? char)
24-
.trim();
25-
}
26-
2720
// Extended prompt context that includes runtime information
2821
export interface PromptContext extends Omit<BasePromptContext, 'toolGuidance'> {
2922
modeId: string;
@@ -80,30 +73,17 @@ export function buildFullPrompt(ctx: PromptContext): string {
8073
? (modelSpecificPrompts[ctx.modelId as keyof typeof modelSpecificPrompts] ?? '')
8174
: '';
8275

83-
// Inject current task state so agent doesn't lose track after OM truncation
84-
let taskSection = '';
85-
const tasks = ctx.state?.tasks as { id?: string; content: string; status: string; activeForm: string }[] | undefined;
86-
if (tasks && tasks.length > 0) {
87-
const lines = tasks.map(t => {
88-
const icon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '▸' : '○';
89-
const id = t.id ? ` {id: ${formatTaskPromptValue(t.id)}}` : '';
90-
return ` ${icon} [${t.status}]${id} ${formatTaskPromptValue(t.content)}`;
91-
});
92-
taskSection = `\n<current-task-list>\n${lines.join('\n')}\n</current-task-list>\n`;
93-
}
76+
// The current task list is carried on the agent state-signal lane (see
77+
// TaskStateProcessor) rather than injected into the cached system prompt. This
78+
// keeps the prompt prefix stable across task updates (preserving prompt cache)
79+
// while still surviving observational-memory truncation.
9480

9581
// Load and inject agent instructions from AGENTS.md/CLAUDE.md files
9682
const configDir = ctx.state?.configDir as string | undefined;
9783
const instructionSources = loadAgentInstructions(ctx.workingDir, configDir);
9884
const instructionsSection = formatAgentInstructions(instructionSources);
9985

100-
const sections = [
101-
base,
102-
taskSection.trim(),
103-
instructionsSection.trim(),
104-
modelSpecific.trim(),
105-
modeSpecific.trim(),
106-
].filter(Boolean);
86+
const sections = [base, instructionsSection.trim(), modelSpecific.trim(), modeSpecific.trim()].filter(Boolean);
10787

10888
return sections.join('\n\n');
10989
}

mastracode/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '@mastra/core/processors';
2828
import { RequestContext } from '@mastra/core/request-context';
2929
import type { PublicSchema } from '@mastra/core/schema';
30+
import { TaskSignalProvider } from '@mastra/core/signals';
3031
import { InMemoryHarness, MastraCompositeStore } from '@mastra/core/storage';
3132
import { DuckDBStore } from '@mastra/duckdb';
3233

@@ -461,7 +462,10 @@ export async function createMastraCode(config?: MastraCodeConfig) {
461462
sampling: { type: 'ratio', rate: 0.3 },
462463
},
463464
},
464-
signals: githubSignals ? [githubSignals] : [],
465+
// TaskSignalProvider bundles the task tools + TaskStateProcessor: it merges
466+
// the tools into the toolset and registers the task state-signal processor,
467+
// so the task list persists across turns and survives OM truncation.
468+
signals: [new TaskSignalProvider(), ...(githubSignals ? [githubSignals] : [])],
465469
inputProcessors: [
466470
new AgentsMDInjector({
467471
getIgnoredInstructionPaths: ({ requestContext }) => {

0 commit comments

Comments
 (0)