Skip to content

Commit 08edb44

Browse files
committed
feat(agent-core-v2): add PYTHINKER_CODE_INFINITE_RETRY retry mode
1 parent 0092543 commit 08edb44

6 files changed

Lines changed: 306 additions & 7 deletions

File tree

docs/configuration/env-vars.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
143143
| `PYTHINKER_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored |
144144
| `PYTHINKER_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored |
145145
| `PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP` | Maximum total attempts for a failing step (including the initial attempt); takes higher priority than `[loop_control] max_attempts_per_step` in `config.toml` (default `10`). The deprecated `PYTHINKER_LOOP_MAX_RETRIES_PER_STEP` is still honored with a warning when this variable is unset | Non-negative integer; invalid values are ignored |
146+
| `PYTHINKER_CODE_INFINITE_RETRY` | Retry every failed LLM request indefinitely — turn steps and background operations such as compaction alike — instead of failing the task; waits use exponential backoff (capped at 32 s) and honor the server's `Retry-After` header, and aborting still cancels immediately. Intended for long-running unattended evaluations against endpoints that may fail temporarily | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
146147
| `PYTHINKER_TOKEN_COUNTING_STRATEGY` | Which context token count is reported externally (the context-size display); takes higher priority than `[token_counting] strategy` in `config.toml` (default `measured+estimated`) | `measured+estimated`, `measured`, `estimated` (case-insensitive); invalid values are ignored |
147148
| `PYTHINKER_WEB_SEARCH_BASE_URL` | API URL of the web search (`WebSearch`) service; takes higher priority than `[services.pymodel_search] base_url` in `config.toml`, and enables the service without that config section. Persisted credentials and custom headers are not forwarded to an env-selected endpoint | Non-blank string; blank values are ignored |
148149
| `PYTHINKER_WEB_SEARCH_API_KEY` | API key of the web search (`WebSearch`) service; replaces both the configured API key and OAuth credential when set | Non-blank string; blank values are ignored |
@@ -159,7 +160,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
159160
| `PYTHINKER_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `PYTHINKER_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` |
160161
| `PYTHINKER_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable |
161162

162-
The three `PYTHINKER_CODE_IDENTITY_*` / `PYTHINKER_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the default `agent-core-v2` engine. The legacy `pythinker` / `pythinker -p` path selected with `PYTHINKER_CODE_LEGACY_FLAG=1` ignores them.
163+
The `PYTHINKER_CODE_INFINITE_RETRY`, `PYTHINKER_CODE_IDENTITY_*`, and `PYTHINKER_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the default `agent-core-v2` engine. The legacy `pythinker` / `pythinker -p` path selected with `PYTHINKER_CODE_LEGACY_FLAG=1` ignores them.
163164

164165
## Diagnostic logs
165166

packages/agent-core-v2/src/_base/utils/retry.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ export interface RetryErrorFields {
1313
readonly statusCode?: number;
1414
}
1515

16+
export function retryBackoffDelay(attemptIndex: number): number {
17+
const base = Math.min(BASE_DELAY_MS * Math.pow(RETRY_FACTOR, attemptIndex), MAX_DELAY_MS);
18+
return base + Math.random() * JITTER_FACTOR * base;
19+
}
20+
1621
export function retryBackoffDelays(maxAttempts: number): number[] {
1722
const count = Math.max(maxAttempts - 1, 0);
1823
const delays: number[] = [];
1924
for (let i = 0; i < count; i += 1) {
20-
const base = Math.min(BASE_DELAY_MS * Math.pow(RETRY_FACTOR, i), MAX_DELAY_MS);
21-
delays.push(base + Math.random() * JITTER_FACTOR * base);
25+
delays.push(retryBackoffDelay(i));
2226
}
2327
return delays;
2428
}

packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { IAgentMediaResolverService } from '#/agent/media/mediaResolver';
1717
import { ISessionUsageService } from '#/session/usage/sessionUsage';
1818
import { IConfigService } from '#/app/config/config';
1919
import {
20+
APIContextOverflowError,
2021
APIRequestTooLargeError,
2122
APIStatusError,
2223
APITimeoutError,
@@ -73,8 +74,15 @@ import {
7374
type LlmRequestToolSchema,
7475
} from './llmRequestOps';
7576
import { isAbortError, linkAbortSignal } from '#/_base/utils/abort';
77+
import { parseBooleanEnv } from '#/_base/utils/env';
7678
import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors';
77-
import { retryErrorFields } from '#/_base/utils/retry';
79+
import {
80+
readRetryAfterMs,
81+
retryBackoffDelay,
82+
retryErrorFields,
83+
sleepForRetry,
84+
} from '#/_base/utils/retry';
85+
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
7886

7987
const EMPTY_TOOL_PARAMETERS: Record<string, unknown> = {
8088
type: 'object',
@@ -86,6 +94,7 @@ const noopOnPart: AgentLLMRequestPartHandler = () => {};
8694
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 180_000;
8795

8896
const STREAM_STALL_REASON = { reason: 'llm-stream-idle-timeout' };
97+
export const PYTHINKER_CODE_INFINITE_RETRY_ENV = 'PYTHINKER_CODE_INFINITE_RETRY';
8998

9099
interface ResolvedLLMRequest {
91100
readonly requester: ModelRequester;
@@ -162,6 +171,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
162171
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
163172
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
164173
@IAgentStateService private readonly states: IAgentStateService,
174+
@IBootstrapService private readonly bootstrap: IBootstrapService,
165175
) {
166176
this.states.contributeState(llmRequestTraceKey);
167177
this.states.contributeState(llmRequesterLastConfigLogSignatureKey);
@@ -456,6 +466,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
456466
};
457467
};
458468

469+
let infiniteRetryAttempt = 0;
459470
for (;;) {
460471
try {
461472
return await run(policy);
@@ -467,12 +478,39 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
467478
signal,
468479
captureMediaStripPolicy,
469480
);
470-
if (nextPolicy === undefined) throw error;
471-
policy = nextPolicy;
481+
if (nextPolicy !== undefined) {
482+
policy = nextPolicy;
483+
continue;
484+
}
485+
const raw = unwrapErrorCause(error);
486+
if (
487+
!this.infiniteRetryEnabled ||
488+
isAbortError(error) ||
489+
signal?.aborted === true ||
490+
raw instanceof APIContextOverflowError
491+
) {
492+
throw error;
493+
}
494+
infiniteRetryAttempt += 1;
495+
const delayMs =
496+
readRetryAfterMs(raw) ??
497+
retryBackoffDelay(infiniteRetryAttempt - 1);
498+
this.log.warn('llm request failed; retrying indefinitely (PYTHINKER_CODE_INFINITE_RETRY)', {
499+
model: request.model.name,
500+
...request.logFields,
501+
attempt: infiniteRetryAttempt,
502+
delayMs,
503+
...retryErrorFields(error),
504+
});
505+
await sleepForRetry(delayMs, signal);
472506
}
473507
}
474508
}
475509

510+
private get infiniteRetryEnabled(): boolean {
511+
return parseBooleanEnv(this.bootstrap.getEnv(PYTHINKER_CODE_INFINITE_RETRY_ENV)) === true;
512+
}
513+
476514
private nextProjectionPolicyForError(
477515
error: unknown,
478516
policy: ProjectionPolicy | undefined,

packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,59 @@ describe('FullCompaction', () => {
666666
await ctx.expectResumeMatches();
667667
});
668668

669+
it('retries any compaction request error indefinitely when PYTHINKER_CODE_INFINITE_RETRY is set', async () => {
670+
vi.stubEnv('PYTHINKER_CODE_INFINITE_RETRY', '1');
671+
let attempts = 0;
672+
const generate: GenerateFn = async () => {
673+
attempts += 1;
674+
if (attempts === 1) throw new APIStatusError(400, 'endpoint broken', null, 1);
675+
if (attempts === 2) throw new APIStatusError(404, 'model not found', null, 1);
676+
return textResult('Recovered compacted summary.');
677+
};
678+
const ctx = testAgent({ generate });
679+
ctx.configure({
680+
provider: CATALOGUED_PROVIDER,
681+
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
682+
});
683+
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
684+
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
685+
const compacted = ctx.once('full_compaction.complete');
686+
const completed = ctx.once('compaction.completed');
687+
688+
await ctx.rpc.beginCompaction({});
689+
await compacted;
690+
await completed;
691+
692+
expect(attempts).toBe(3);
693+
await ctx.expectResumeMatches();
694+
});
695+
696+
it('lets context overflow reach compaction shrink instead of retrying when PYTHINKER_CODE_INFINITE_RETRY is set', async () => {
697+
vi.stubEnv('PYTHINKER_CODE_INFINITE_RETRY', '1');
698+
let attempts = 0;
699+
const generate: GenerateFn = async () => {
700+
attempts += 1;
701+
if (attempts === 1) throw new APIContextOverflowError(400, 'context length exceeded');
702+
return textResult('Recovered compacted summary.');
703+
};
704+
const ctx = testAgent({ generate });
705+
ctx.configure({
706+
provider: CATALOGUED_PROVIDER,
707+
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
708+
});
709+
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
710+
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
711+
const compacted = ctx.once('full_compaction.complete');
712+
const completed = ctx.once('compaction.completed');
713+
714+
await ctx.rpc.beginCompaction({});
715+
await compacted;
716+
await completed;
717+
718+
expect(attempts).toBe(2);
719+
await ctx.expectResumeMatches();
720+
});
721+
669722
it('recovers from an image-format rejection with a media-stripped resend', async () => {
670723
let attempts = 0;
671724
let sawMedia = false;

packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import {
1313
type ProjectionPolicy,
1414
} from '#/agent/contextProjector/contextProjector';
1515
import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService';
16-
import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService';
16+
import { AgentLLMRequesterService, PYTHINKER_CODE_INFINITE_RETRY_ENV } from '#/agent/llmRequester/llmRequesterService';
1717
import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
18+
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
1819
import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting';
1920
import { IAgentProfileService } from '#/agent/profile/profile';
2021
import { IAgentStateService } from '#/agent/state/agentState';
@@ -29,7 +30,10 @@ import type { Event2 } from '#/app/event/event2';
2930
import { IEventBus } from '#/app/event/eventBus';
3031
import {
3132
APIConnectionError,
33+
APIContextOverflowError,
3234
APIEmptyResponseError,
35+
APIProviderQuotaExhaustedError,
36+
APIProviderRateLimitError,
3337
APIRequestTooLargeError,
3438
APIStatusError,
3539
APITimeoutError,
@@ -56,6 +60,7 @@ import { Error2, ErrorCodes } from '#/errors';
5660
import { IEventDispatcher } from '#/state/eventDispatcher';
5761
import type { WireRecord } from '#/wire/record';
5862
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
63+
import { stubBootstrap } from '../../app/bootstrap/stubs';
5964

6065
import {
6166
recordingWireLog,
@@ -163,9 +168,11 @@ function createService(
163168
readonly mediaResolver?: Partial<IAgentMediaResolverService>;
164169
readonly contextMessages?: Message[];
165170
readonly llmConfig?: LlmConfig;
171+
readonly env?: Record<string, string>;
166172
} = {},
167173
) {
168174
const ix = disposables.add(new TestInstantiationService());
175+
ix.stub(IBootstrapService, stubBootstrap('/tmp/pythinker-code-llm-requester-test', options.env ?? {}));
169176
const thinkingLevel = options.thinkingLevel ?? 'off';
170177
const profile: Partial<IAgentProfileService> = {
171178
resolveModelContext: () => ({
@@ -357,6 +364,121 @@ describe('AgentLLMRequesterService strict resend', () => {
357364
});
358365
});
359366

367+
describe('AgentLLMRequesterService infinite retry', () => {
368+
afterEach(() => {
369+
vi.useRealTimers();
370+
});
371+
372+
it('retries every request error while PYTHINKER_CODE_INFINITE_RETRY is set', async () => {
373+
vi.useFakeTimers();
374+
const calls = { value: 0 };
375+
const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken'), [
376+
new APIStatusError(404, 'model not found'),
377+
new APIConnectionError('socket hang up'),
378+
new APIProviderQuotaExhaustedError('quota exhausted'),
379+
]);
380+
const { service } = createService(requester, undefined, {
381+
env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' },
382+
});
383+
384+
const promise = service.request();
385+
await vi.runAllTimersAsync();
386+
const finish = await promise;
387+
388+
expect(calls.value).toBe(5);
389+
expect(finish.message.content).toEqual([{ type: 'text', text: 'ok' }]);
390+
});
391+
392+
it('honors the provider retry-after delay while retrying indefinitely', async () => {
393+
const calls = { value: 0 };
394+
const requester = createRequester(calls, new APIProviderRateLimitError('slow down', null, 1));
395+
const { service } = createService(requester, undefined, {
396+
env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' },
397+
});
398+
399+
const startedAt = Date.now();
400+
await service.request();
401+
402+
expect(calls.value).toBe(2);
403+
expect(Date.now() - startedAt).toBeLessThan(500);
404+
});
405+
406+
it('stops retrying when the caller aborts during the backoff wait', async () => {
407+
vi.useFakeTimers();
408+
const calls = { value: 0 };
409+
const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken'));
410+
const { service } = createService(requester, undefined, {
411+
env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' },
412+
});
413+
const controller = new AbortController();
414+
setTimeout(() => controller.abort(new Error('stop')), 100);
415+
416+
const promise = service.request({}, undefined, controller.signal);
417+
const assertion = expect(promise).rejects.toThrow('stop');
418+
await vi.runAllTimersAsync();
419+
await assertion;
420+
421+
expect(calls.value).toBe(1);
422+
});
423+
424+
it('keeps deterministic projection recovery ahead of infinite retry', async () => {
425+
vi.useFakeTimers();
426+
const calls = { value: 0 };
427+
const requester = createRequester(calls, new APIRequestTooLargeError(413, 'Request Entity Too Large'));
428+
const { service } = createService(requester, undefined, {
429+
env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' },
430+
});
431+
432+
await service.request();
433+
434+
expect(calls.value).toBe(2);
435+
});
436+
437+
it('lets context overflow reach deterministic recovery instead of retrying', async () => {
438+
vi.useFakeTimers();
439+
const calls = { value: 0 };
440+
const requester = createRequester(
441+
calls,
442+
new APIContextOverflowError(400, 'context length exceeded'),
443+
);
444+
const { service } = createService(requester, undefined, {
445+
env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' },
446+
});
447+
448+
await expect(service.request()).rejects.toBeInstanceOf(APIContextOverflowError);
449+
expect(calls.value).toBe(1);
450+
});
451+
452+
it('retries operation requests indefinitely', async () => {
453+
vi.useFakeTimers();
454+
const calls = { value: 0 };
455+
const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken'), [
456+
new APIStatusError(404, 'model not found'),
457+
]);
458+
const { service } = createService(requester, undefined, {
459+
env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' },
460+
});
461+
462+
const promise = service.request({
463+
source: { type: 'operation', requestKind: 'full_compaction' },
464+
});
465+
await vi.runAllTimersAsync();
466+
await promise;
467+
468+
expect(calls.value).toBe(3);
469+
});
470+
471+
it('does not retry when the switch is unset', async () => {
472+
vi.useFakeTimers();
473+
const calls = { value: 0 };
474+
const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken'));
475+
const { service } = createService(requester, undefined);
476+
477+
await expect(service.request()).rejects.toMatchObject({ statusCode: 400 });
478+
expect(calls.value).toBe(1);
479+
});
480+
});
481+
360482
describe('AgentLLMRequesterService media-stripped resend', () => {
361483
const IMAGE_FORMAT_400 = new APIStatusError(
362484
400,

0 commit comments

Comments
 (0)