Skip to content

Commit bc802fe

Browse files
committed
chore: sync release branch with main
2 parents dd23b59 + 0e299a2 commit bc802fe

17 files changed

Lines changed: 1254 additions & 2 deletions

packages/agent-core-v2/docs/config-manifest.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ extra_skill_dirs = []
198198
# env:
199199
# max_steps_per_turn <- PYTHINKER_LOOP_MAX_STEPS_PER_TURN (custom parse)
200200
# max_attempts_per_step <- PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP (custom parse; deprecated fallback PYTHINKER_LOOP_MAX_RETRIES_PER_STEP)
201+
# turn_budget_tokens <- PYTHINKER_LOOP_TURN_BUDGET_TOKENS (custom parse)
201202
# ##########################################################################
202203

203204
[loop_control]
@@ -206,6 +207,8 @@ extra_skill_dirs = []
206207
# max_ralph_iterations: integer
207208
# reserved_context_size: integer
208209
# compaction_trigger_ratio: number
210+
# fallback_model: string
211+
# turn_budget_tokens: integer
209212

210213
# ##########################################################################
211214
# mcp

packages/agent-core-v2/docs/state-manifest.d.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
// references become '(circular)', and class instances collapse to a '(ClassName)'
2828
// marker — the wire shape of an entry is the JSON projection of the type here.
2929
//
30-
// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 94 keys)
30+
// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 99 keys)
3131
// App
3232
// Workspace
3333
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
@@ -148,6 +148,11 @@
148148
// toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts
149149
// tower src/features/tower/towerOps.ts
150150
// turn src/agent/loop/turnOps.ts
151+
// turnBudget.continuations src/agent/turnBudget/turnBudgetService.ts
152+
// turnBudget.lastDeltaTokens src/agent/turnBudget/turnBudgetService.ts
153+
// turnBudget.tokensUsed src/agent/turnBudget/turnBudgetService.ts
154+
// turnRecovery.modelFallbackUsed src/agent/turnRecovery/modelFallbackService.ts
155+
// turnRecovery.outputTokenAttempts src/agent/turnRecovery/outputTokenRecoveryService.ts
151156
// userTool src/agent/userTool/userToolOps.ts
152157

153158
/** App-scope keys registered into IAppStateService. */
@@ -1478,6 +1483,14 @@ export interface AgentStateSnapshot {
14781483
'toolExecutor.toolCallDupTypes': Map<string, /* ToolCallDupType — packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts */ 'same_step' | 'cross_step'>;
14791484
// src/agent/toolSelect/toolSelectService.ts
14801485
'toolSelect.pendingLoaded': Set<string>;
1486+
// src/agent/turnBudget/turnBudgetService.ts
1487+
'turnBudget.continuations': number;
1488+
'turnBudget.lastDeltaTokens': number;
1489+
'turnBudget.tokensUsed': number;
1490+
// src/agent/turnRecovery/modelFallbackService.ts
1491+
'turnRecovery.modelFallbackUsed': boolean;
1492+
// src/agent/turnRecovery/outputTokenRecoveryService.ts
1493+
'turnRecovery.outputTokenAttempts': number;
14811494
// src/agent/userTool/userToolOps.ts
14821495
// replayable · durable — folds: ToolsRegisterUserTool, ToolsUnregisterUserTool
14831496
'userTool': /* UserToolModelState — packages/agent-core-v2/src/agent/userTool/userToolOps.ts */ Map<string, /* UserToolRegistration — packages/agent-core-v2/src/agent/userTool/userTool.ts */ {

packages/agent-core-v2/src/agent/loop/configSection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const LOOP_CONTROL_SECTION = 'loopControl';
88

99
export const LOOP_MAX_STEPS_PER_TURN_ENV = 'PYTHINKER_LOOP_MAX_STEPS_PER_TURN';
1010
export const LOOP_MAX_ATTEMPTS_PER_STEP_ENV = 'PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP';
11+
export const LOOP_TURN_BUDGET_TOKENS_ENV = 'PYTHINKER_LOOP_TURN_BUDGET_TOKENS';
1112
/** Deprecated former name of {@link LOOP_MAX_ATTEMPTS_PER_STEP_ENV}. */
1213
export const LOOP_MAX_RETRIES_PER_STEP_ENV = 'PYTHINKER_LOOP_MAX_RETRIES_PER_STEP';
1314

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

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

4044
export const stripLoopControlEnv = stripEnvBoundFields(loopControlEnvBindings);

packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { TurnStarted } from '#/agent/loop/turnEvents';
2424
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
2525
import { IAgentStateService } from '#/agent/state/agentState';
2626
import { IEventDispatcher } from '#/state/eventDispatcher';
27+
import { IAgentModelFallbackService } from '#/agent/turnRecovery/modelFallback';
2728

2829
import { IAgentStepRetryService } from './stepRetry';
2930

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

63+
private static stepAborted(context: LoopErrorContext): boolean {
64+
return context.signal.aborted || context.currentStep?.signal.aborted === true;
65+
}
66+
6267
constructor(
6368
@IAgentLoopService private readonly loopService: IAgentLoopService,
6469
@IConfigService private readonly config: IConfigService,
6570
@IEventBus private readonly eventBus: IEventBus,
6671
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
6772
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
6873
@IAgentStateService private readonly states: IAgentStateService,
74+
@IAgentModelFallbackService private readonly modelFallback: IAgentModelFallbackService,
6975
) {
7076
super();
7177
this.states.contributeState(stepRetryLastFailedDriverIdKey);
@@ -124,7 +130,12 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry
124130
);
125131
if (this.failedAttempts >= maxAttempts) {
126132
this.resetAttempts();
127-
return false;
133+
if (AgentStepRetryService.stepAborted(context)) return false;
134+
const switched = await this.modelFallback.tryFallbackSwitch(context);
135+
if (!switched) return false;
136+
if (AgentStepRetryService.stepAborted(context)) return false;
137+
context.retry(driver, { at: 'head' });
138+
return true;
128139
}
129140

130141
const error = unwrapErrorCause(context.error);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
2+
3+
export const TURN_BUDGET_CONTINUATION_FLAG_ID = 'turn-budget-continuation';
4+
export const TURN_BUDGET_CONTINUATION_FLAG_ENV =
5+
'PYTHINKER_CODE_EXPERIMENTAL_TURN_BUDGET_CONTINUATION';
6+
7+
export const turnBudgetContinuationFlag: FlagDefinitionInput = {
8+
id: TURN_BUDGET_CONTINUATION_FLAG_ID,
9+
title: 'Turn budget continuation',
10+
description:
11+
'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.',
12+
env: TURN_BUDGET_CONTINUATION_FLAG_ENV,
13+
default: false,
14+
surface: 'core',
15+
};
16+
17+
registerFlagDefinition(turnBudgetContinuationFlag);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createDecorator } from '#/_base/di/instantiation';
2+
3+
/**
4+
* Continues a turn toward a configured output-token target by injecting
5+
* continuation nudges while progress holds, stopping on diminishing returns.
6+
*/
7+
export interface IAgentTurnBudgetService {
8+
readonly _serviceBrand: undefined;
9+
}
10+
11+
export const IAgentTurnBudgetService =
12+
createDecorator<IAgentTurnBudgetService>('agentTurnBudgetService');
13+
14+
/** Fraction of the configured token target a turn must reach before stopping naturally. */
15+
export const TURN_BUDGET_COMPLETION_THRESHOLD = 0.9;
16+
/** Per-step output-token delta below which a step counts as low-progress. */
17+
export const TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS = 500;
18+
/** Continuations after which consecutive low-progress deltas stop the turn. */
19+
export const TURN_BUDGET_MAX_DIMINISHING_CONTINUATIONS = 3;
20+
21+
/** Builds the meta nudge injected before each budget continuation. */
22+
export function turnBudgetNudgeText(pct: number, used: number, budget: number): string {
23+
return `Stopped at ${pct}% of token target (${used} / ${budget}). Keep working - do not summarize.`;
24+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { Disposable } from '#/_base/di/lifecycle';
2+
import { LifecycleScope } from '#/app/scopes';
3+
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
4+
import { defineState } from '#/state/state';
5+
import { createUserMessage } from '#/kosong/contract/message';
6+
import type { ContextMessage } from '#/agent/contextMemory/types';
7+
import type { AfterStepContext } from '#/agent/loop/loop';
8+
import { IAgentLoopService } from '#/agent/loop/loop';
9+
import { TurnStarted } from '#/agent/loop/turnEvents';
10+
import { StepRequest } from '#/agent/loop/stepRequest';
11+
import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection';
12+
import { IConfigService } from '#/app/config/config';
13+
import { IEventBus } from '#/app/event/eventBus';
14+
import { IFlagService } from '#/app/flag/flag';
15+
import { ITelemetryService } from '#/app/telemetry/telemetry';
16+
import { IAgentStateService } from '#/agent/state/agentState';
17+
import { TURN_BUDGET_CONTINUATION_FLAG_ID } from './flag';
18+
import {
19+
IAgentTurnBudgetService,
20+
TURN_BUDGET_COMPLETION_THRESHOLD,
21+
TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS,
22+
TURN_BUDGET_MAX_DIMINISHING_CONTINUATIONS,
23+
turnBudgetNudgeText,
24+
} from './turnBudget';
25+
26+
export const turnBudgetTokensUsedKey = defineState<number>('turnBudget.tokensUsed', () => 0);
27+
export const turnBudgetContinuationsKey = defineState<number>('turnBudget.continuations', () => 0);
28+
export const turnBudgetLastDeltaTokensKey = defineState<number>(
29+
'turnBudget.lastDeltaTokens',
30+
() => 0,
31+
);
32+
33+
class TurnBudgetContinuationRequest extends StepRequest {
34+
readonly kind = 'turn-budget-continuation';
35+
36+
constructor(private readonly message: ContextMessage) {
37+
super();
38+
}
39+
40+
override resolveContextMessages(): readonly ContextMessage[] {
41+
return [this.message];
42+
}
43+
}
44+
45+
export class AgentTurnBudgetService extends Disposable implements IAgentTurnBudgetService {
46+
declare readonly _serviceBrand: undefined;
47+
48+
constructor(
49+
@IAgentLoopService private readonly loopService: IAgentLoopService,
50+
@IFlagService private readonly flags: IFlagService,
51+
@IConfigService private readonly config: IConfigService,
52+
@IEventBus private readonly eventBus: IEventBus,
53+
@ITelemetryService private readonly telemetry: ITelemetryService,
54+
@IAgentStateService private readonly states: IAgentStateService,
55+
) {
56+
super();
57+
this.states.contributeState(turnBudgetTokensUsedKey);
58+
this.states.contributeState(turnBudgetContinuationsKey);
59+
this.states.contributeState(turnBudgetLastDeltaTokensKey);
60+
this._register(this.eventBus.subscribe(TurnStarted, () => this.reset()));
61+
this._register(
62+
this.loopService.hooks.onDidFinishStep.register('turn-budget', async (context, next) => {
63+
await next();
64+
this.maybeContinue(context);
65+
}),
66+
);
67+
}
68+
69+
private get tokensUsed(): number {
70+
return this.states.get(turnBudgetTokensUsedKey);
71+
}
72+
73+
private set tokensUsed(value: number) {
74+
this.states.set(turnBudgetTokensUsedKey, value);
75+
}
76+
77+
private get continuations(): number {
78+
return this.states.get(turnBudgetContinuationsKey);
79+
}
80+
81+
private set continuations(value: number) {
82+
this.states.set(turnBudgetContinuationsKey, value);
83+
}
84+
85+
private get lastDeltaTokens(): number {
86+
return this.states.get(turnBudgetLastDeltaTokensKey);
87+
}
88+
89+
private set lastDeltaTokens(value: number) {
90+
this.states.set(turnBudgetLastDeltaTokensKey, value);
91+
}
92+
93+
private reset(): void {
94+
this.tokensUsed = 0;
95+
this.continuations = 0;
96+
this.lastDeltaTokens = 0;
97+
}
98+
99+
private budgetTokens(): number {
100+
return this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.turnBudgetTokens ?? 0;
101+
}
102+
103+
private maybeContinue(context: AfterStepContext): void {
104+
if (!this.flags.enabled(TURN_BUDGET_CONTINUATION_FLAG_ID)) return;
105+
if (context.stopTurn || context.signal.aborted) return;
106+
const budget = this.budgetTokens();
107+
if (budget <= 0) return;
108+
109+
const delta = context.usage.output;
110+
const used = this.tokensUsed + delta;
111+
const previousDelta = this.lastDeltaTokens;
112+
this.lastDeltaTokens = delta;
113+
this.tokensUsed = used;
114+
115+
if (context.finishReason === 'tool_calls') return;
116+
if (context.finishReason !== 'completed') return;
117+
if (this.loopService.status().hasPendingRequests) return;
118+
119+
const diminishing =
120+
this.continuations >= TURN_BUDGET_MAX_DIMINISHING_CONTINUATIONS &&
121+
delta < TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS &&
122+
previousDelta < TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS;
123+
if (diminishing) return;
124+
if (used >= budget * TURN_BUDGET_COMPLETION_THRESHOLD) return;
125+
126+
const pct = Math.round((used / budget) * 100);
127+
this.continuations += 1;
128+
this.telemetry.track2('budget_continuation', {
129+
turn_id: context.turnId,
130+
continuation_count: this.continuations,
131+
tokens_used: used,
132+
budget_tokens: budget,
133+
});
134+
const message: ContextMessage = {
135+
...createUserMessage(turnBudgetNudgeText(pct, used, budget)),
136+
origin: { kind: 'retry', trigger: 'token_budget' },
137+
};
138+
this.loopService.enqueue(new TurnBudgetContinuationRequest(message));
139+
}
140+
}
141+
142+
registerScopedService(
143+
LifecycleScope.Agent,
144+
IAgentTurnBudgetService,
145+
AgentTurnBudgetService,
146+
ScopeActivation.OnScopeCreated,
147+
'turnBudget',
148+
);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
2+
3+
export const OUTPUT_TOKEN_RECOVERY_FLAG_ID = 'output-token-recovery';
4+
export const OUTPUT_TOKEN_RECOVERY_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_OUTPUT_TOKEN_RECOVERY';
5+
6+
export const MODEL_FALLBACK_FLAG_ID = 'model-fallback';
7+
export const MODEL_FALLBACK_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_MODEL_FALLBACK';
8+
9+
export const outputTokenRecoveryFlag: FlagDefinitionInput = {
10+
id: OUTPUT_TOKEN_RECOVERY_FLAG_ID,
11+
title: 'Output token recovery',
12+
description:
13+
'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.',
14+
env: OUTPUT_TOKEN_RECOVERY_FLAG_ENV,
15+
default: false,
16+
surface: 'core',
17+
};
18+
19+
export const modelFallbackFlag: FlagDefinitionInput = {
20+
id: MODEL_FALLBACK_FLAG_ID,
21+
title: 'Model fallback',
22+
description:
23+
'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.',
24+
env: MODEL_FALLBACK_FLAG_ENV,
25+
default: false,
26+
surface: 'core',
27+
};
28+
29+
registerFlagDefinition(outputTokenRecoveryFlag);
30+
registerFlagDefinition(modelFallbackFlag);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */
2+
import { createDecorator } from '#/_base/di/instantiation';
3+
import { Event2 } from '#/app/event/event2';
4+
import type { LoopErrorContext } from '#/agent/loop/loop';
5+
6+
/**
7+
* Switches the agent to the configured fallback model when step retries are
8+
* exhausted on persistent retryable provider errors, so the retrying layer can
9+
* resend the failed step on the fallback.
10+
*/
11+
export interface IAgentModelFallbackService {
12+
readonly _serviceBrand: undefined;
13+
14+
/**
15+
* Switches the agent profile to `loopControl.fallback_model` when allowed
16+
* (flag on, model configured and different from the current one, not yet
17+
* used this turn). Returns true when the switch happened and the caller
18+
* should retry the failed driver.
19+
*/
20+
tryFallbackSwitch(context: LoopErrorContext): Promise<boolean>;
21+
}
22+
23+
export const IAgentModelFallbackService =
24+
createDecorator<IAgentModelFallbackService>('agentModelFallbackService');
25+
26+
export interface ModelFallbackSwitchedPayload {
27+
readonly turnId: number;
28+
readonly step?: number;
29+
readonly fromModel: string;
30+
readonly toModel: string;
31+
}
32+
33+
export class ModelFallbackSwitched extends Event2<ModelFallbackSwitchedPayload> {
34+
static override readonly type = 'turn.model_fallback.switched';
35+
static override readonly observable = true;
36+
}
37+
export interface ModelFallbackSwitched extends ModelFallbackSwitchedPayload {}

0 commit comments

Comments
 (0)