Skip to content

Commit 2c482f9

Browse files
committed
fix: recover bounded context and implement engineering spec
1 parent cd5d72b commit 2c482f9

84 files changed

Lines changed: 4752 additions & 988 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎package-lock.json‎

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

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@
4949
"engines": {
5050
"node": "^22.12.0 || >=23.3.0"
5151
},
52-
"version": "1.0.7",
52+
"version": "1.0.8",
5353
"dependencies": {
5454
"get-east-asian-width": "^1.6.0",
55-
"@ponythewhite/base-context": "1.0.7"
55+
"@ponythewhite/base-context": "1.0.8"
5656
},
5757
"overrides": {
5858
"rimraf": "6.1.2",

‎packages/agent/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## [1.0.8] - 2026-09-18
4+
5+
- Allowed one deterministic context reprepare before the existing single request-recovery attempt, without restarting the invocation or completed tools.
6+
37
## [1.0.7] - 2026-09-18
48

59
- Updated lockstep packaging for the Base Context 1.0.7 session-resume fix.

‎packages/agent/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ponythewhite/base-context-agent",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"description": "Synerise base-context agent runtime with context management and native recovery",
55
"type": "module",
66
"main": "./dist/index.js",
@@ -19,7 +19,7 @@
1919
"prepublishOnly": "npm --prefix ../.. run build:source"
2020
},
2121
"dependencies": {
22-
"@ponythewhite/base-context-ai": "1.0.7",
22+
"@ponythewhite/base-context-ai": "1.0.8",
2323
"typebox": "^1.3.9"
2424
},
2525
"keywords": [

‎packages/agent/src/agent-loop.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,7 @@ async function streamAssistantResponse(
597597
emit: AgentEventSink,
598598
streamFn?: StreamFn,
599599
allowRecovery = true,
600+
allowReprepare = true,
600601
): Promise<AssistantMessage> {
601602
const build: { projection: AgentContextBuildResult } = { projection: undefined };
602603
let addedPartial = false;
@@ -767,18 +768,22 @@ async function streamAssistantResponse(
767768
isLocalRequestPreparationError(outcome.error) &&
768769
config.recoverRequestPreparation
769770
) {
770-
let recovered: boolean;
771+
let recovered: boolean | "reprepare";
771772
try {
772773
recovered = await config.recoverRequestPreparation(outcome.error, signal);
773774
} catch (error) {
774775
// Preserve the real owner error, including a post-ACK compaction failure.
775776
markLocalRequestPreparationError(error);
776777
throw error;
777778
}
778-
if (recovered) {
779-
// Rebuild/adopt the committed owner view. Keep this invocation's output collector,
780-
// completed tools and emitted messages; only this unsent request gets one reprepare.
781-
return streamAssistantResponse(context, config, signal, emit, streamFn, false);
779+
if (recovered === "reprepare" && allowReprepare) {
780+
// A bounded deterministic change (for example, rebasing a TaskFrame) must
781+
// not consume the one recovery available if the new request still cannot fit.
782+
return streamAssistantResponse(context, config, signal, emit, streamFn, true, false);
783+
}
784+
if (recovered === true) {
785+
// Keep this invocation's output collector, completed tools and emitted messages.
786+
return streamAssistantResponse(context, config, signal, emit, streamFn, false, false);
782787
}
783788
}
784789
throw outcome.error;

‎packages/agent/src/types.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
210210
/** Internal state mirror for adopted working sets. Awaited and excluded from provider options. */
211211
onContextAdopted?: (messages: AgentMessage[]) => void | Promise<void>;
212212

213-
/** Native owner recovery after an unsent request's projection has been released. */
214-
recoverRequestPreparation?: (error: unknown, signal?: AbortSignal) => Promise<boolean>;
213+
/** Native owner recovery after release. One deterministic reprepare may precede one recovery. */
214+
recoverRequestPreparation?: (error: unknown, signal?: AbortSignal) => Promise<boolean | "reprepare">;
215215

216216
/** Retry a settled provider failure within this invocation and its output allowance. */
217217
recoverProviderFailure?: (message: AssistantMessage, signal?: AbortSignal) => Promise<boolean>;

‎packages/ai/CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## [1.0.8] - 2026-09-18
4+
5+
- Fixed Codex socket reuse after authentication or endpoint changes without interrupting active requests.
6+
- Fixed Anthropic tool schemas losing root constraints and definitions or sharing mutable schema data with callers.
7+
- Fixed Codex retry waits retaining abort listeners and ignoring the provider retry delay.
8+
- Fixed duplicate fallback item IDs when a Responses assistant message contains multiple unsigned text blocks.
9+
- Kept generated Responses item IDs stable for source-backed requests, with a fresh context epoch after restarting the session owner.
10+
- Respected disabled Codex prompt-cache affinity without changing account routing or connection reuse.
11+
- Added opt-in OpenAI token estimation and near-limit Responses input counting while preserving output reserves and control-call accounting.
12+
- Exposed conservative plaintext sizing through the existing request token meter.
13+
314
## [1.0.7] - 2026-09-18
415

516
- Updated lockstep packaging for the Base Context 1.0.7 session-resume fix.

‎packages/ai/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ponythewhite/base-context-ai",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"description": "Synerise base-context unified LLM API and provider configuration",
55
"type": "module",
66
"main": "./dist/index.js",
@@ -80,6 +80,7 @@
8080
"@mistralai/mistralai": "^2.2.0",
8181
"typebox": "^1.3.9",
8282
"chalk": "^5.6.2",
83+
"js-tiktoken": "1.0.21",
8384
"openai": "6.47.0",
8485
"partial-json": "^0.1.7",
8586
"proxy-agent": "^6.5.0",

‎packages/ai/src/providers/anthropic.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,17 +1279,11 @@ function convertTools(
12791279
if (!tools) return [];
12801280

12811281
return tools.map((tool, index) => {
1282-
const schema = tool.parameters as { properties?: unknown; required?: string[] };
1283-
12841282
return {
12851283
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
12861284
description: tool.description,
12871285
...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),
1288-
input_schema: {
1289-
type: "object",
1290-
properties: schema.properties ?? {},
1291-
required: schema.required ?? [],
1292-
},
1286+
input_schema: structuredClone(tool.parameters) as Anthropic.Messages.Tool.InputSchema,
12931287
...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
12941288
};
12951289
});

‎packages/ai/src/providers/openai-codex-responses.ts‎

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
StreamOptions,
3434
Usage,
3535
} from "../types.js";
36+
import { abortableDelay } from "../utils/abortable-delay.js";
3637
import {
3738
appendAssistantMessageDiagnostic,
3839
createAssistantMessageDiagnostic,
@@ -128,20 +129,6 @@ function codexTransportError(error: unknown): Error {
128129
);
129130
}
130131

131-
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
132-
return new Promise((resolve, reject) => {
133-
if (signal?.aborted) {
134-
reject(new Error("Request was aborted"));
135-
return;
136-
}
137-
const timeout = setTimeout(resolve, ms);
138-
signal?.addEventListener("abort", () => {
139-
clearTimeout(timeout);
140-
reject(new Error("Request was aborted"));
141-
});
142-
});
143-
}
144-
145132
export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses", OpenAICodexResponsesOptions> = (
146133
model: Model<"openai-codex-responses">,
147134
context: Context,
@@ -383,8 +370,14 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
383370
}
384371
if (!(attempt < MAX_RETRIES && isRetryableError(lastError))) throw lastError;
385372
await attempts.settle("failed");
386-
const delayMs = BASE_DELAY_MS * 2 ** attempt;
387-
await sleep(delayMs, options?.signal);
373+
const retryAfter = response?.headers.get("retry-after");
374+
const retryAfterMs = retryAfter
375+
? /^\d+(\.\d+)?$/.test(retryAfter)
376+
? Number(retryAfter) * 1000
377+
: Date.parse(retryAfter) - Date.now()
378+
: NaN;
379+
const delayMs = Number.isFinite(retryAfterMs) ? Math.max(0, retryAfterMs) : BASE_DELAY_MS * 2 ** attempt;
380+
await abortableDelay(delayMs, options?.signal);
388381
}
389382

390383
if (!response?.ok) {
@@ -453,6 +446,7 @@ function buildRequestBody(
453446
includeSystemPrompt: false,
454447
onProjection,
455448
pendingPublicMessageGroups: options?.attempts?.pendingPublicMessageGroups,
449+
responsesMessageIds: options?.attempts?.responsesMessageIds,
456450
});
457451

458452
const body: RequestBody = {
@@ -463,7 +457,7 @@ function buildRequestBody(
463457
input: messages,
464458
text: { verbosity: options?.textVerbosity || "low" },
465459
include: ["reasoning.encrypted_content"],
466-
prompt_cache_key: options?.sessionId,
460+
prompt_cache_key: options?.cacheRetention === "none" ? undefined : options?.sessionId,
467461
tool_choice: "auto",
468462
parallel_tool_calls: true,
469463
};
@@ -702,6 +696,13 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
702696

703697
const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
704698
const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1000;
699+
const WEBSOCKET_AUTH_HEADERS = [
700+
"authorization",
701+
"chatgpt-account-id",
702+
"openai-organization",
703+
"openai-project",
704+
"x-api-key",
705+
] as const;
705706

706707
type WebSocketEventType = "open" | "message" | "error" | "close";
707708
type WebSocketListener = (event: unknown) => void;
@@ -723,6 +724,7 @@ interface CachedWebSocketContinuationState {
723724
interface CachedWebSocketConnection {
724725
socket: WebSocketLike;
725726
url: string;
727+
headers: Headers;
726728
busy: boolean;
727729
idleTimer?: ReturnType<typeof setTimeout>;
728730
continuation?: CachedWebSocketContinuationState;
@@ -886,7 +888,9 @@ function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocke
886888
entry.idleTimer = setTimeout(() => {
887889
if (entry.busy) return;
888890
closeWebSocketSilently(entry.socket, 1000, "idle_timeout");
889-
websocketSessionCache.delete(sessionId);
891+
if (websocketSessionCache.get(sessionId) === entry) {
892+
websocketSessionCache.delete(sessionId);
893+
}
890894
}, SESSION_WEBSOCKET_CACHE_TTL_MS);
891895
}
892896

@@ -1005,16 +1009,20 @@ async function acquireWebSocket(
10051009
clearTimeout(cached.idleTimer);
10061010
cached.idleTimer = undefined;
10071011
}
1008-
if (!cached.busy && isWebSocketReusable(cached.socket)) {
1012+
const matchesConnection =
1013+
cached.url === url && WEBSOCKET_AUTH_HEADERS.every((name) => cached.headers.get(name) === headers.get(name));
1014+
if (!cached.busy && matchesConnection && isWebSocketReusable(cached.socket)) {
10091015
cached.busy = true;
10101016
return {
10111017
socket: cached.socket,
10121018
entry: cached,
10131019
reused: true,
10141020
release: ({ keep } = {}) => {
1015-
if (!keep || !isWebSocketReusable(cached.socket)) {
1021+
if (!keep || !isWebSocketReusable(cached.socket) || websocketSessionCache.get(sessionId) !== cached) {
10161022
closeWebSocketSilently(cached.socket);
1017-
websocketSessionCache.delete(sessionId);
1023+
if (websocketSessionCache.get(sessionId) === cached) {
1024+
websocketSessionCache.delete(sessionId);
1025+
}
10181026
return;
10191027
}
10201028
cached.busy = false;
@@ -1032,21 +1040,22 @@ async function acquireWebSocket(
10321040
},
10331041
};
10341042
}
1035-
if (!isWebSocketReusable(cached.socket)) {
1036-
closeWebSocketSilently(cached.socket);
1043+
cached.continuation = undefined;
1044+
closeWebSocketSilently(cached.socket);
1045+
if (websocketSessionCache.get(sessionId) === cached) {
10371046
websocketSessionCache.delete(sessionId);
10381047
}
10391048
}
10401049

10411050
const socket = await connectWebSocket(url, headers, signal, timeoutMs);
1042-
const entry: CachedWebSocketConnection = { socket, url, busy: true };
1051+
const entry: CachedWebSocketConnection = { socket, url, headers: new Headers(headers), busy: true };
10431052
websocketSessionCache.set(sessionId, entry);
10441053
return {
10451054
socket,
10461055
entry,
10471056
reused: false,
10481057
release: ({ keep } = {}) => {
1049-
if (!keep || !isWebSocketReusable(entry.socket)) {
1058+
if (!keep || !isWebSocketReusable(entry.socket) || websocketSessionCache.get(sessionId) !== entry) {
10501059
closeWebSocketSilently(entry.socket);
10511060
if (entry.idleTimer) clearTimeout(entry.idleTimer);
10521061
if (websocketSessionCache.get(sessionId) === entry) {

0 commit comments

Comments
 (0)