Skip to content

Commit 245a9a3

Browse files
authored
fix(harness,libsql): keep agent loop alive after tool suspension (#17990)
1 parent 218d952 commit 245a9a3

9 files changed

Lines changed: 258 additions & 27 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@mastra/core": patch
3+
"@mastra/libsql": patch
4+
---
5+
6+
Keep the agent loop alive after a tool suspension (e.g. `ask_user`, `request_access`).
7+
8+
Two related fixes that stop the agent from appearing to quit mid-task right after a suspended tool resumes:
9+
10+
- **Harness resume step budget**: `resumeStream()` was called without a step budget, so a resumed run merged over the agent's small default `maxSteps` and stopped after a single step. The harness now threads a shared set of run options (`maxSteps`, `savePerStep`, `modelSettings`, etc.) into both the initial stream and the resume via one helper, so the two paths can no longer drift.
11+
- **LibSQL `busy_timeout` survival**: bump `@libsql/client` to `^0.17.4` and add a configurable connection timeout so `PRAGMA busy_timeout` survives connections created after `transaction()` (libsql-client-ts#288/#345). This reduces `SQLITE_BUSY` retry-exhaustion stalls under the evented engine's concurrent writes.

packages/core/src/harness/__tests__/harness-tool-suspension.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,4 +348,72 @@ describe('Harness: tool suspension and resumption', () => {
348348
// Yolo mode should disable tool approval gating on resume, matching sendMessage's behavior
349349
expect(resumeOptions.requireToolApproval).toBe(false);
350350
});
351+
352+
it('should forward the full run budget (maxSteps) to resumeStream so the resumed run does not stop mid-task', async () => {
353+
// Regression: resumeStream previously omitted maxSteps, so the resumed run
354+
// merged over the agent's small default budget and ended with reason
355+
// "complete" after a few steps — the agent stopped mid-task after ask_user.
356+
const confirmTool = createTool({
357+
id: 'confirm-action',
358+
description: 'Confirms an action with the user',
359+
inputSchema: z.object({ action: z.string() }),
360+
execute: async (input: { action: string }, context?: any) => {
361+
// Resume-aware: continue instead of re-suspending once resumeData arrives,
362+
// so the resumed run can actually complete.
363+
const resumeData = context?.agent?.resumeData ?? context?.workflow?.resumeData ?? context?.resumeData;
364+
if (resumeData) {
365+
return { result: `Action "${input.action}" confirmed`, resumed: resumeData };
366+
}
367+
const suspend = context?.suspend ?? context?.agent?.suspend;
368+
if (!suspend) throw new Error('suspend not available in context');
369+
await suspend({ action: input.action });
370+
return { result: `Action "${input.action}" confirmed` };
371+
},
372+
});
373+
374+
const agent = new Agent({
375+
id: 'test-agent-budget-resume',
376+
name: 'Test Agent Budget Resume',
377+
instructions: 'You confirm actions.',
378+
model: new MastraLanguageModelV2Mock({
379+
doStream: (() => {
380+
let callCount = 0;
381+
return async () => {
382+
callCount++;
383+
return { stream: callCount === 1 ? createToolCallStream() : createTextStream() };
384+
};
385+
})(),
386+
}),
387+
tools: { confirmAction: confirmTool },
388+
});
389+
390+
const storage = new InMemoryStore();
391+
const mastra = new Mastra({
392+
agents: { 'test-agent-budget-resume': agent },
393+
logger: false,
394+
storage,
395+
});
396+
397+
const registeredAgent = mastra.getAgent('test-agent-budget-resume');
398+
const resumeStreamSpy = vi.spyOn(registeredAgent, 'resumeStream');
399+
400+
const harness = new Harness({
401+
id: 'test-harness-budget-resume',
402+
storage,
403+
modes: [{ id: 'default', name: 'Default', default: true, agent: registeredAgent }],
404+
initialState: { yolo: true } as any,
405+
});
406+
407+
await harness.init();
408+
await harness.createThread();
409+
410+
await harness.sendMessage({ content: 'Deploy to production' });
411+
await harness.respondToToolSuspension({ resumeData: { confirmed: true } });
412+
413+
expect(resumeStreamSpy).toHaveBeenCalled();
414+
const [, resumeOptions] = resumeStreamSpy.mock.calls[0] as [any, any];
415+
// Must match the budget used for the initial stream, not the agent default.
416+
expect(resumeOptions.maxSteps).toBe(1000);
417+
expect(resumeOptions.savePerStep).toBe(false);
418+
});
351419
});

packages/core/src/harness/harness.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@ export function describeNonSuccessFinishReason(reason: string, providerMetadata:
153153
*/
154154
const FABLE_FALLBACK_MODEL = 'claude-opus-4-8';
155155

156+
/**
157+
* Step budget applied to every harness-driven agent run.
158+
*
159+
* This MUST be passed to both the initial stream and `resumeStream`: when a run
160+
* suspends on an interactive tool (e.g. `ask_user`) and then resumes, the
161+
* resumed call merges over the agent's *default* options, whose `maxSteps` is
162+
* small (~5). Without re-supplying this budget the resumed run is silently
163+
* capped and ends with `reason:"complete"` after a few steps — the agent stops
164+
* mid-task even though it promised to continue. See {@link buildSharedRunOptions}.
165+
*/
166+
const HARNESS_MAX_STEPS = 1000;
167+
156168
/**
157169
* Returns Anthropic `providerOptions` that enable a server-side fallback to
158170
* {@link FABLE_FALLBACK_MODEL} when the active model is `claude-fable-5`, and
@@ -1881,33 +1893,42 @@ export class Harness<TState = {}> {
18811893
this.abortRequested = false;
18821894
this.abortController ??= new AbortController();
18831895
const requestContext = await this.buildRequestContext(requestContextInput);
1884-
const isYolo = (this.state as Record<string, unknown>).yolo === true;
18851896
const streamOptions: Record<string, unknown> = {
1897+
...this.buildSharedRunOptions(),
18861898
memory: { thread: this.currentThreadId, resource: this.resourceId },
18871899
abortSignal: this.abortController.signal,
18881900
requestContext,
1889-
maxSteps: 1000,
1890-
savePerStep: false,
1891-
requireToolApproval: !isYolo,
1892-
modelSettings: { temperature: 1 },
18931901
...(tracingContext && { tracingContext }),
18941902
...(tracingOptions && { tracingOptions }),
18951903
};
18961904
streamOptions.toolsets = await this.buildToolsets(requestContext);
18971905

1906+
return streamOptions;
1907+
}
1908+
1909+
/**
1910+
* Options that every harness-driven agent run must carry — the initial stream
1911+
* AND every `resumeStream`. Centralized so the two paths can't drift: a
1912+
* missing `maxSteps` on resume silently caps the resumed run at the agent's
1913+
* small default and ends it mid-task (see {@link HARNESS_MAX_STEPS}).
1914+
*/
1915+
private buildSharedRunOptions(): Record<string, unknown> {
1916+
const isYolo = (this.state as Record<string, unknown>).yolo === true;
1917+
const shared: Record<string, unknown> = {
1918+
maxSteps: HARNESS_MAX_STEPS,
1919+
savePerStep: false,
1920+
requireToolApproval: !isYolo,
1921+
modelSettings: { temperature: 1 },
1922+
};
1923+
18981924
// Auto-enable Anthropic server-side fallbacks for fable-5 so a classifier
18991925
// block is transparently retried on the fallback model instead of failing.
19001926
const fableFallback = buildFableFallbackProviderOptions(this.getCurrentModelId());
19011927
if (fableFallback) {
1902-
const existing = streamOptions.providerOptions as Record<string, unknown> | undefined;
1903-
const existingAnthropic = (existing?.anthropic as Record<string, unknown> | undefined) ?? {};
1904-
streamOptions.providerOptions = {
1905-
...existing,
1906-
anthropic: { ...existingAnthropic, ...fableFallback.anthropic },
1907-
};
1928+
shared.providerOptions = { anthropic: { ...fableFallback.anthropic } };
19081929
}
19091930

1910-
return streamOptions;
1931+
return shared;
19111932
}
19121933

19131934
private async drainFollowUpQueue(options?: {
@@ -3683,16 +3704,19 @@ export class Harness<TState = {}> {
36833704
this.displayState.pendingSuspensions.delete(toolCallId);
36843705

36853706
const requestContext = await this.buildRequestContext(requestContextInput);
3686-
const isYolo = (this.state as Record<string, unknown>).yolo === true;
3707+
36873708
const output = await agent.resumeStream(resumeData, {
3709+
// Re-supply the shared run budget (maxSteps, etc). Without it the resumed
3710+
// run merges over the agent's small default maxSteps and stops mid-task.
3711+
...this.buildSharedRunOptions(),
36883712
runId: suspension.runId,
36893713
toolCallId,
3690-
requireToolApproval: !isYolo,
36913714
memory: this.currentThreadId ? { thread: this.currentThreadId, resource: this.resourceId } : undefined,
36923715
abortSignal: this.abortController.signal,
36933716
requestContext,
36943717
toolsets: await this.buildToolsets(requestContext),
36953718
});
3719+
36963720
await this.processStream(output, requestContext);
36973721
}
36983722

pnpm-lock.yaml

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ minimumReleaseAgeExclude:
8080
- 'mastracode'
8181
- '@earendil-works/pi-tui'
8282
- railway@3.3.1
83+
# Turso-recommended busy_timeout fix (libsql-client-ts#288/#345). Adopted
84+
# immediately on their guidance; pinned so the release-age gate still applies
85+
# to any later @libsql/client versions.
86+
- '@libsql/client@0.17.4'
87+
- '@libsql/core@0.17.4'
8388
trustPolicyExclude:
8489
# Security fix for GHSA middleware-based route protection bypass (CVE).
8590
# 3.47.4 is published with provenance attestation rather than trusted publisher,

stores/libsql/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
},
2828
"license": "Apache-2.0",
2929
"dependencies": {
30-
"@libsql/client": "^0.17.3"
30+
"@libsql/client": "^0.17.4"
3131
},
3232
"devDependencies": {
3333
"@internal/lint": "workspace:*",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import type { createClient } from '@libsql/client';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
7+
import { resolveClient, DEFAULT_CONNECTION_TIMEOUT_MS } from './db';
8+
import { LibSQLStore } from './index';
9+
10+
type TestClient = ReturnType<typeof createClient>;
11+
12+
const getClient = (store: LibSQLStore): TestClient => (store as unknown as { client: TestClient }).client;
13+
14+
const readBusyTimeout = async (client: TestClient): Promise<number> => {
15+
const rs = await client.execute('PRAGMA busy_timeout');
16+
return Number(rs.rows[0]!['timeout']);
17+
};
18+
19+
describe('LibSQL busy_timeout configuration', () => {
20+
let tmpDir: string;
21+
22+
beforeEach(() => {
23+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'libsql-busy-timeout-test-'));
24+
});
25+
26+
afterEach(() => {
27+
fs.rmSync(tmpDir, { recursive: true, force: true });
28+
});
29+
30+
it('applies the default busy_timeout to a local file store', async () => {
31+
const store = new LibSQLStore({ id: 'busy-default', url: `file:${path.join(tmpDir, 'default.db')}` });
32+
try {
33+
await store.init();
34+
35+
expect(await readBusyTimeout(getClient(store))).toBe(DEFAULT_CONNECTION_TIMEOUT_MS);
36+
} finally {
37+
await store.close();
38+
}
39+
});
40+
41+
it('honors a custom connectionTimeoutMs', async () => {
42+
const store = new LibSQLStore({
43+
id: 'busy-custom',
44+
url: `file:${path.join(tmpDir, 'custom.db')}`,
45+
connectionTimeoutMs: 1234,
46+
});
47+
try {
48+
await store.init();
49+
50+
expect(await readBusyTimeout(getClient(store))).toBe(1234);
51+
} finally {
52+
await store.close();
53+
}
54+
});
55+
56+
it('keeps the busy_timeout after a transaction() opens a new connection (libsql-client-ts#288)', async () => {
57+
const client = resolveClient({ url: `file:${path.join(tmpDir, 'txn.db')}`, connectionTimeoutMs: 4321 });
58+
59+
try {
60+
const txn = await client.transaction('write');
61+
await txn.execute('CREATE TABLE t (a)');
62+
await txn.commit();
63+
64+
// transaction() hands the client's connection to the transaction and lazily
65+
// opens a new one; the timeout must apply to that new connection too.
66+
expect(await readBusyTimeout(client)).toBe(4321);
67+
} finally {
68+
client.close();
69+
}
70+
});
71+
72+
it('resolveClient defaults the busy_timeout for local urls', async () => {
73+
const client = resolveClient({ url: `file:${path.join(tmpDir, 'resolve.db')}` });
74+
75+
try {
76+
expect(await readBusyTimeout(client)).toBe(DEFAULT_CONNECTION_TIMEOUT_MS);
77+
} finally {
78+
client.close();
79+
}
80+
});
81+
});

stores/libsql/src/storage/db/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,24 @@ export type LibSQLDomainBaseConfig = {
3535
* @default 100
3636
*/
3737
initialBackoffMs?: number;
38+
/**
39+
* SQLite `busy_timeout` (in milliseconds) applied to the underlying connection
40+
* for local (`file:`/`:memory:`) databases. Lets a write wait for a lock to
41+
* clear instead of failing immediately with `SQLITE_BUSY`. Requires
42+
* `@libsql/client` >= 0.17.4 (see libsql-client-ts#288/#345). Ignored when an
43+
* existing `client` is supplied.
44+
* @default 5000
45+
*/
46+
connectionTimeoutMs?: number;
3847
};
3948

49+
/**
50+
* Default SQLite `busy_timeout` (ms) for local LibSQL connections. Chosen to
51+
* comfortably exceed the write-retry backoff window so contended writes block
52+
* briefly rather than surfacing as `SQLITE_BUSY` errors.
53+
*/
54+
export const DEFAULT_CONNECTION_TIMEOUT_MS = 5000;
55+
4056
/**
4157
* Configuration for LibSQL domains - accepts either credentials or an existing client
4258
*/
@@ -63,9 +79,14 @@ export function resolveClient(config: LibSQLDomainConfig): Client {
6379
if ('client' in config) {
6480
return config.client;
6581
}
82+
const isLocal = config.url.startsWith('file:') || config.url.includes(':memory:');
83+
const timeout = config.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS;
6684
return createClient({
6785
url: config.url,
6886
...(config.authToken ? { authToken: config.authToken } : {}),
87+
// Only local sqlite3 connections honor `busy_timeout`; remote contention is
88+
// resolved server-side, so passing it there is meaningless.
89+
...(isLocal ? { timeout } : {}),
6990
});
7091
}
7192

0 commit comments

Comments
 (0)