Skip to content
Merged
3 changes: 3 additions & 0 deletions packages/agent-core-v2/docs/config-manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ extra_skill_dirs = []
# env:
# max_steps_per_turn <- PYTHINKER_LOOP_MAX_STEPS_PER_TURN (custom parse)
# max_attempts_per_step <- PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP (custom parse; deprecated fallback PYTHINKER_LOOP_MAX_RETRIES_PER_STEP)
# turn_budget_tokens <- PYTHINKER_LOOP_TURN_BUDGET_TOKENS (custom parse)
# ##########################################################################

[loop_control]
Expand All @@ -206,6 +207,8 @@ extra_skill_dirs = []
# max_ralph_iterations: integer
# reserved_context_size: integer
# compaction_trigger_ratio: number
# fallback_model: string
# turn_budget_tokens: integer

# ##########################################################################
# mcp
Expand Down
15 changes: 14 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 94 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 99 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
Expand Down Expand Up @@ -148,6 +148,11 @@
// toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts
// tower src/features/tower/towerOps.ts
// turn src/agent/loop/turnOps.ts
// turnBudget.continuations src/agent/turnBudget/turnBudgetService.ts
// turnBudget.lastDeltaTokens src/agent/turnBudget/turnBudgetService.ts
// turnBudget.tokensUsed src/agent/turnBudget/turnBudgetService.ts
// turnRecovery.modelFallbackUsed src/agent/turnRecovery/modelFallbackService.ts
// turnRecovery.outputTokenAttempts src/agent/turnRecovery/outputTokenRecoveryService.ts
// userTool src/agent/userTool/userToolOps.ts

/** App-scope keys registered into IAppStateService. */
Expand Down Expand Up @@ -1478,6 +1483,14 @@ export interface AgentStateSnapshot {
'toolExecutor.toolCallDupTypes': Map<string, /* ToolCallDupType — packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts */ 'same_step' | 'cross_step'>;
// src/agent/toolSelect/toolSelectService.ts
'toolSelect.pendingLoaded': Set<string>;
// src/agent/turnBudget/turnBudgetService.ts
'turnBudget.continuations': number;
'turnBudget.lastDeltaTokens': number;
'turnBudget.tokensUsed': number;
// src/agent/turnRecovery/modelFallbackService.ts
'turnRecovery.modelFallbackUsed': boolean;
// src/agent/turnRecovery/outputTokenRecoveryService.ts
'turnRecovery.outputTokenAttempts': number;
// src/agent/userTool/userToolOps.ts
// replayable · durable — folds: ToolsRegisterUserTool, ToolsUnregisterUserTool
'userTool': /* UserToolModelState — packages/agent-core-v2/src/agent/userTool/userToolOps.ts */ Map<string, /* UserToolRegistration — packages/agent-core-v2/src/agent/userTool/userTool.ts */ {
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/agent/loop/configSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const LOOP_CONTROL_SECTION = 'loopControl';

export const LOOP_MAX_STEPS_PER_TURN_ENV = 'PYTHINKER_LOOP_MAX_STEPS_PER_TURN';
export const LOOP_MAX_ATTEMPTS_PER_STEP_ENV = 'PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP';
export const LOOP_TURN_BUDGET_TOKENS_ENV = 'PYTHINKER_LOOP_TURN_BUDGET_TOKENS';
/** Deprecated former name of {@link LOOP_MAX_ATTEMPTS_PER_STEP_ENV}. */
export const LOOP_MAX_RETRIES_PER_STEP_ENV = 'PYTHINKER_LOOP_MAX_RETRIES_PER_STEP';

Expand All @@ -17,6 +18,8 @@ export const LoopControlSchema = z.object({
maxRalphIterations: z.number().int().min(-1).optional(),
reservedContextSize: z.number().int().min(0).optional(),
compactionTriggerRatio: z.number().min(0.5).max(0.99).optional(),
fallbackModel: z.string().min(1).optional(),
turnBudgetTokens: z.number().int().min(0).optional(),
});

export type LoopControl = z.infer<typeof LoopControlSchema>;
Expand All @@ -35,6 +38,7 @@ export const loopControlEnvBindings: EnvBindings<LoopControl> = envBindings(Loop
deprecatedEnv: LOOP_MAX_RETRIES_PER_STEP_ENV,
parse: parseNonNegativeInt,
},
turnBudgetTokens: { env: LOOP_TURN_BUDGET_TOKENS_ENV, parse: parseNonNegativeInt },
});

export const stripLoopControlEnv = stripEnvBoundFields(loopControlEnvBindings);
Expand Down
13 changes: 12 additions & 1 deletion packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { TurnStarted } from '#/agent/loop/turnEvents';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentStateService } from '#/agent/state/agentState';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { IAgentModelFallbackService } from '#/agent/turnRecovery/modelFallback';

import { IAgentStepRetryService } from './stepRetry';

Expand Down Expand Up @@ -59,13 +60,18 @@ export const stepRetryFailedAttemptsKey = defineState<number>(
export class AgentStepRetryService extends Disposable implements IAgentStepRetryService {
declare readonly _serviceBrand: undefined;

private static stepAborted(context: LoopErrorContext): boolean {
return context.signal.aborted || context.currentStep?.signal.aborted === true;
}

constructor(
@IAgentLoopService private readonly loopService: IAgentLoopService,
@IConfigService private readonly config: IConfigService,
@IEventBus private readonly eventBus: IEventBus,
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
@IAgentStateService private readonly states: IAgentStateService,
@IAgentModelFallbackService private readonly modelFallback: IAgentModelFallbackService,
) {
super();
this.states.contributeState(stepRetryLastFailedDriverIdKey);
Expand Down Expand Up @@ -124,7 +130,12 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry
);
if (this.failedAttempts >= maxAttempts) {
this.resetAttempts();
return false;
if (AgentStepRetryService.stepAborted(context)) return false;
const switched = await this.modelFallback.tryFallbackSwitch(context);
if (!switched) return false;
if (AgentStepRetryService.stepAborted(context)) return false;
context.retry(driver, { at: 'head' });
return true;
}

const error = unwrapErrorCause(context.error);
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-core-v2/src/agent/turnBudget/flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';

export const TURN_BUDGET_CONTINUATION_FLAG_ID = 'turn-budget-continuation';
export const TURN_BUDGET_CONTINUATION_FLAG_ENV =
'PYTHINKER_CODE_EXPERIMENTAL_TURN_BUDGET_CONTINUATION';

export const turnBudgetContinuationFlag: FlagDefinitionInput = {
id: TURN_BUDGET_CONTINUATION_FLAG_ID,
title: 'Turn budget continuation',
description:
'When loopControl.turn_budget_tokens is set, keep a naturally-stopping turn working toward the output-token target with continuation nudges until the threshold is reached or progress diminishes.',
env: TURN_BUDGET_CONTINUATION_FLAG_ENV,
default: false,
surface: 'core',
};

registerFlagDefinition(turnBudgetContinuationFlag);
24 changes: 24 additions & 0 deletions packages/agent-core-v2/src/agent/turnBudget/turnBudget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createDecorator } from '#/_base/di/instantiation';

/**
* Continues a turn toward a configured output-token target by injecting
* continuation nudges while progress holds, stopping on diminishing returns.
*/
export interface IAgentTurnBudgetService {
readonly _serviceBrand: undefined;
}

export const IAgentTurnBudgetService =
createDecorator<IAgentTurnBudgetService>('agentTurnBudgetService');

/** Fraction of the configured token target a turn must reach before stopping naturally. */
export const TURN_BUDGET_COMPLETION_THRESHOLD = 0.9;
/** Per-step output-token delta below which a step counts as low-progress. */
export const TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS = 500;
/** Continuations after which consecutive low-progress deltas stop the turn. */
export const TURN_BUDGET_MAX_DIMINISHING_CONTINUATIONS = 3;

/** Builds the meta nudge injected before each budget continuation. */
export function turnBudgetNudgeText(pct: number, used: number, budget: number): string {
return `Stopped at ${pct}% of token target (${used} / ${budget}). Keep working - do not summarize.`;
}
148 changes: 148 additions & 0 deletions packages/agent-core-v2/src/agent/turnBudget/turnBudgetService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { defineState } from '#/state/state';
import { createUserMessage } from '#/kosong/contract/message';
import type { ContextMessage } from '#/agent/contextMemory/types';
import type { AfterStepContext } from '#/agent/loop/loop';
import { IAgentLoopService } from '#/agent/loop/loop';
import { TurnStarted } from '#/agent/loop/turnEvents';
import { StepRequest } from '#/agent/loop/stepRequest';
import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection';
import { IConfigService } from '#/app/config/config';
import { IEventBus } from '#/app/event/eventBus';
import { IFlagService } from '#/app/flag/flag';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentStateService } from '#/agent/state/agentState';
import { TURN_BUDGET_CONTINUATION_FLAG_ID } from './flag';
import {
IAgentTurnBudgetService,
TURN_BUDGET_COMPLETION_THRESHOLD,
TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS,
TURN_BUDGET_MAX_DIMINISHING_CONTINUATIONS,
turnBudgetNudgeText,
} from './turnBudget';

export const turnBudgetTokensUsedKey = defineState<number>('turnBudget.tokensUsed', () => 0);
export const turnBudgetContinuationsKey = defineState<number>('turnBudget.continuations', () => 0);
export const turnBudgetLastDeltaTokensKey = defineState<number>(
'turnBudget.lastDeltaTokens',
() => 0,
);

class TurnBudgetContinuationRequest extends StepRequest {
readonly kind = 'turn-budget-continuation';

constructor(private readonly message: ContextMessage) {
super();
}

override resolveContextMessages(): readonly ContextMessage[] {
return [this.message];
}
}

export class AgentTurnBudgetService extends Disposable implements IAgentTurnBudgetService {
declare readonly _serviceBrand: undefined;

constructor(
@IAgentLoopService private readonly loopService: IAgentLoopService,
@IFlagService private readonly flags: IFlagService,
@IConfigService private readonly config: IConfigService,
@IEventBus private readonly eventBus: IEventBus,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentStateService private readonly states: IAgentStateService,
) {
super();
this.states.contributeState(turnBudgetTokensUsedKey);
this.states.contributeState(turnBudgetContinuationsKey);
this.states.contributeState(turnBudgetLastDeltaTokensKey);
this._register(this.eventBus.subscribe(TurnStarted, () => this.reset()));
this._register(
this.loopService.hooks.onDidFinishStep.register('turn-budget', async (context, next) => {
await next();
this.maybeContinue(context);
}),
);
}

private get tokensUsed(): number {
return this.states.get(turnBudgetTokensUsedKey);
}

private set tokensUsed(value: number) {
this.states.set(turnBudgetTokensUsedKey, value);
}

private get continuations(): number {
return this.states.get(turnBudgetContinuationsKey);
}

private set continuations(value: number) {
this.states.set(turnBudgetContinuationsKey, value);
}

private get lastDeltaTokens(): number {
return this.states.get(turnBudgetLastDeltaTokensKey);
}

private set lastDeltaTokens(value: number) {
this.states.set(turnBudgetLastDeltaTokensKey, value);
}

private reset(): void {
this.tokensUsed = 0;
this.continuations = 0;
this.lastDeltaTokens = 0;
}

private budgetTokens(): number {
return this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.turnBudgetTokens ?? 0;
}

private maybeContinue(context: AfterStepContext): void {
if (!this.flags.enabled(TURN_BUDGET_CONTINUATION_FLAG_ID)) return;
if (context.stopTurn || context.signal.aborted) return;
const budget = this.budgetTokens();
if (budget <= 0) return;

const delta = context.usage.output;
const used = this.tokensUsed + delta;
const previousDelta = this.lastDeltaTokens;
this.lastDeltaTokens = delta;
this.tokensUsed = used;

if (context.finishReason === 'tool_calls') return;
if (context.finishReason !== 'completed') return;
if (this.loopService.status().hasPendingRequests) return;

const diminishing =
this.continuations >= TURN_BUDGET_MAX_DIMINISHING_CONTINUATIONS &&
delta < TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS &&
previousDelta < TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS;
if (diminishing) return;
if (used >= budget * TURN_BUDGET_COMPLETION_THRESHOLD) return;

const pct = Math.round((used / budget) * 100);
this.continuations += 1;
this.telemetry.track2('budget_continuation', {
turn_id: context.turnId,
continuation_count: this.continuations,
tokens_used: used,
budget_tokens: budget,
});
const message: ContextMessage = {
...createUserMessage(turnBudgetNudgeText(pct, used, budget)),
origin: { kind: 'retry', trigger: 'token_budget' },
};
this.loopService.enqueue(new TurnBudgetContinuationRequest(message));
}
}

registerScopedService(
LifecycleScope.Agent,
IAgentTurnBudgetService,
AgentTurnBudgetService,
ScopeActivation.OnScopeCreated,
'turnBudget',
);
30 changes: 30 additions & 0 deletions packages/agent-core-v2/src/agent/turnRecovery/flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';

export const OUTPUT_TOKEN_RECOVERY_FLAG_ID = 'output-token-recovery';
export const OUTPUT_TOKEN_RECOVERY_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_OUTPUT_TOKEN_RECOVERY';

export const MODEL_FALLBACK_FLAG_ID = 'model-fallback';
export const MODEL_FALLBACK_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_MODEL_FALLBACK';

export const outputTokenRecoveryFlag: FlagDefinitionInput = {
id: OUTPUT_TOKEN_RECOVERY_FLAG_ID,
title: 'Output token recovery',
description:
'When a model response ends truncated at the output token limit with no tool calls, inject a resume nudge and continue the same turn instead of ending it truncated. Caps recoveries per turn.',
env: OUTPUT_TOKEN_RECOVERY_FLAG_ENV,
default: false,
surface: 'core',
};

export const modelFallbackFlag: FlagDefinitionInput = {
id: MODEL_FALLBACK_FLAG_ID,
title: 'Model fallback',
description:
'When step retries are exhausted on persistent retryable provider errors, switch the agent to the configured loopControl.fallback_model once per turn and retry the failed step there.',
env: MODEL_FALLBACK_FLAG_ENV,
default: false,
surface: 'core',
};

registerFlagDefinition(outputTokenRecoveryFlag);
registerFlagDefinition(modelFallbackFlag);
37 changes: 37 additions & 0 deletions packages/agent-core-v2/src/agent/turnRecovery/modelFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */
import { createDecorator } from '#/_base/di/instantiation';
import { Event2 } from '#/app/event/event2';
import type { LoopErrorContext } from '#/agent/loop/loop';

/**
* Switches the agent to the configured fallback model when step retries are
* exhausted on persistent retryable provider errors, so the retrying layer can
* resend the failed step on the fallback.
*/
export interface IAgentModelFallbackService {
readonly _serviceBrand: undefined;

/**
* Switches the agent profile to `loopControl.fallback_model` when allowed
* (flag on, model configured and different from the current one, not yet
* used this turn). Returns true when the switch happened and the caller
* should retry the failed driver.
*/
tryFallbackSwitch(context: LoopErrorContext): Promise<boolean>;
}

export const IAgentModelFallbackService =
createDecorator<IAgentModelFallbackService>('agentModelFallbackService');

export interface ModelFallbackSwitchedPayload {
readonly turnId: number;
readonly step?: number;
readonly fromModel: string;
readonly toModel: string;
}

export class ModelFallbackSwitched extends Event2<ModelFallbackSwitchedPayload> {
static override readonly type = 'turn.model_fallback.switched';
static override readonly observable = true;
}
export interface ModelFallbackSwitched extends ModelFallbackSwitchedPayload {}
Loading
Loading