Skip to content

Commit 48fd229

Browse files
committed
feat: add model request fitting and idle timeouts
Use advertised model output limits, fit requests to available context, preserve answer room for thinking budgets, and record effective request settings. Add configurable byte-idle transport timeouts without an absolute turn deadline. Fixes #39 Signed-off-by: Hari Srinivasan <harisrini21@gmail.com>
1 parent 7f21e94 commit 48fd229

49 files changed

Lines changed: 1229 additions & 123 deletions

Some content is hidden

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

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ A session belongs to the daemon, not to the terminal that created it. Closing a
129129
- `axl -r` opens the all-placement resume picker.
130130
- `axl <session-id>` resumes a known session directly.
131131
- `session.interrupt` is the explicit cancellation operation.
132+
- `/request` shows or changes the daemon-owned output ceiling and HTTP idle timeout. The default output ceiling is the model maximum, fitted to available context; the idle timeout is five minutes and refreshes on response bytes.
132133
- Daemon restart recovery reconciles accepted operations against canonical history before serving clients.
133134

134135
Resume uses a frozen, paged snapshot followed by an acknowledged live event stream. The SDK rejects altered duplicates, detects gaps, and replaces a projection from an authoritative snapshot when a cursor cannot be resumed safely.

SETUP.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ axl daemon stop --interrupt --yes
6666

6767
Use the same `--unsafe`, `--sandbox`, `--image`, or `--socket` selection as the running daemon. Status and stop do not require provider credentials. Restart refuses to switch data directories. Stop and restart refuse active work unless `--interrupt` explicitly authorizes cancellation. `--yes` confirms disconnecting clients. A changed confirmation snapshot requires a fresh command. Exit codes are 0 for success, 1 for errors, 2 for refused or stale confirmation, and 3 for a missing daemon on status or stop. Restart starts a missing daemon.
6868

69-
An incompatible session wire fails loudly and points to these commands. No daemon is automatically replaced on a version mismatch. Host-control version 1 operates independently of session wire version 10 on a separate connection to the same owner-only Unix socket. It does not bypass the session handshake.
69+
An incompatible session wire fails loudly and points to these commands. No daemon is automatically replaced on a version mismatch. Host-control version 1 operates independently of session wire version 11 on a separate connection to the same owner-only Unix socket. It does not bypass the session handshake.
7070

7171
If graceful cleanup fails or exceeds the host's ten-second wait, inspect `axl daemon status`. Shutdown can still be running. The TUI offers a separate force confirmation when available. From the CLI, explicitly request:
7272

@@ -78,6 +78,24 @@ Force is accepted only after graceful shutdown has begun, and only by the same d
7878

7979
Daemons from builds before host control cannot be recovered through these new commands. For that one-time transition, inspect the old process with operating-system tools, verify its command, owner, and socket, then send SIGTERM to that verified process. A PID in `.axl-data.lock` alone is not proof. Do not delete an active lock or kill every Node process. Once the old process has exited, normal startup reclaims its stale socket and lock. Preserved sessions remain resumable.
8080

81+
### Model request limits
82+
83+
Ordinary requests default to the selected model's advertised output maximum. Axl reduces that ceiling only when needed to fit the estimated input plus 4,096 reserved tokens inside the model context window. Every request sends an explicit provider output ceiling. The selected reasoning level remains unchanged. Providers that use explicit thinking-token budgets reserve at least 1,024 tokens for the answer.
84+
85+
The HTTP idle timeout defaults to five minutes and applies separately while waiting for response headers and between response-body bytes. Streaming bytes, including SSE heartbeat comments, refresh it. It is not an absolute turn or request deadline. A timeout is reported separately from user cancellation and is never automatically retried after dispatch because provider acceptance may be uncertain.
86+
87+
Show or change daemon-owned settings in the TUI:
88+
89+
```text
90+
/request
91+
/request output 8192
92+
/request output model
93+
/request idle 300000
94+
/request idle disabled
95+
```
96+
97+
For a new CLI session, use `--max-output-tokens <n|model>` and `--http-idle-timeout <milliseconds>`. Zero disables the idle timeout. Effective settings are recorded in canonical history and shown by `/status`. Axl has no numerical model-call ceiling or absolute turn-duration limit. Session budgets and proactive compaction remain separate features.
98+
8199

82100
Inspect local sandbox support without configuring provider credentials:
83101

docs/architecture/web-protocol.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ This document specifies typed RPC, negotiation, errors, package ownership, and t
1313

1414
## Current baseline
1515

16-
Wire version 10 uses newline-delimited JSON over a Unix socket. It includes typed request and result envelopes, initialization, capability negotiation, structured errors, idempotency keys, subscription identities, paged snapshots, acknowledged opaque cursors, presence, daemon security reporting, direct shell events, transient activity, session-bound blobs, workspace review, session profiles, web-tool selection, manual compaction, steering, follow-ups, and canonical model-retry attempts.
16+
Wire version 11 uses newline-delimited JSON over a Unix socket. It includes typed request and result envelopes, initialization, capability negotiation, structured errors, idempotency keys, subscription identities, paged snapshots, acknowledged opaque cursors, presence, daemon security reporting, direct shell events, transient activity, session-bound blobs, workspace review, session profiles, web-tool selection, manual compaction, steering, follow-ups, and canonical model-retry attempts.
1717

18-
The TUI consumes these contracts through `packages/sdk`. Version 10 adds the terminal connection error `daemon_stopping`, so explicit shutdown does not trigger automatic replacement. Host-control version 1 is separate from session wire negotiation and is available only to trusted process hosts.
18+
The TUI consumes these contracts through `packages/sdk`. Version 11 adds daemon-owned model request settings plus canonical effective-request events. Host-control version 1 remains separate from session wire negotiation and is available only to trusted process hosts.
1919

2020
## Versioning
2121

22-
The current wire version is 10. Version 8 introduced typed envelopes, initialization, errors, retry metadata, subscriptions, cursors, acknowledgements, and presence. Version 9 adds the canonical `model.retry_scheduled` event. Version 10 adds `daemon_stopping` as a pre-RPC and universal RPC error. Compatible capability additions that do not alter accepted wire data do not require a bump. Pre-1.0 clients require an exact wire-version match.
22+
The current wire version is 11. Version 8 introduced typed envelopes, initialization, errors, retry metadata, subscriptions, cursors, acknowledgements, and presence. Version 9 adds the canonical `model.retry_scheduled` event. Version 10 adds `daemon_stopping` as a pre-RPC and universal RPC error. Version 11 adds `config.request` and `model.request_configured` events and request settings in session create and configure RPCs. Compatible capability additions that do not alter accepted wire data do not require a bump. Pre-1.0 clients require an exact wire-version match.
2323

2424
The daemon sends `hello` first:
2525

packages/ai/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
# `@axl/ai`
55

66
This package keeps provider-specific behavior outside the kernel. It defines provider and model contracts, credential lookup, thinking levels, tool dialects, deterministic test models, and the Azure OpenAI Responses adapter with Axl's built-in model catalog.
7+
8+
Ordinary requests use the selected model's advertised output maximum, reduced only to fit estimated input plus a 4,096-token context reserve. The adapter sends the effective ceiling explicitly. Model HTTP transport uses Undici with a configurable five-minute default inactivity timeout for headers and response-body bytes. Streaming bytes refresh the timeout, zero disables it, and no absolute request deadline is imposed.

packages/ai/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"typecheck": "tsc --noEmit"
2525
},
2626
"dependencies": {
27-
"@axl/protocol": "workspace:*"
27+
"@axl/protocol": "workspace:*",
28+
"undici": "8.10.2"
2829
}
2930
}

packages/ai/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export * from "./sse.ts";
1616
export * from "./stream.ts";
1717
export * from "./thinking.ts";
1818
export * from "./usage.ts";
19+
export * from "./request-configuration.ts";

packages/ai/src/model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export interface ModelRequest {
6767
readonly tools?: readonly ToolDeclaration[];
6868
readonly thinkingLevel?: ThinkingLevel;
6969
readonly maxOutputTokens?: number;
70+
readonly httpIdleTimeoutMs?: number;
71+
readonly estimatedInputTokens?: number;
7072
readonly toolChoice?: "auto" | "required" | "none";
7173
/** Resolves content-addressed media without placing bytes in canonical events. */
7274
readonly readBlob?: (reference: BlobReference) => Promise<Uint8Array>;

packages/ai/src/openai-responses.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-FileCopyrightText: 2026 Hari Srinivasan
22
// SPDX-FileCopyrightText: 2026 Kaushik Kumar
3+
// SPDX-FileCopyrightText: 2026 Shaan Narendran
34
// SPDX-License-Identifier: Apache-2.0
45

56
// Axl-native OpenAI Responses codec and transport implementation.
@@ -12,6 +13,9 @@ import type {
1213
Usage,
1314
} from "@axl/protocol";
1415

16+
import { EnvHttpProxyAgent, fetch as modelFetch } from "undici";
17+
import { fitModelRequest } from "./request-configuration.ts";
18+
1519
import { AuthError, type ResolvedAuth } from "./auth.ts";
1620
import { assertModelSupports } from "./capabilities.ts";
1721
import type { AuthMethod, ModelInfo, ModelRequest, ModelStreamEvent } from "./model.ts";
@@ -20,6 +24,21 @@ import { decodeSseStream, type SseFrame } from "./sse.ts";
2024

2125
/** OpenAI Responses rejects max_output_tokens below 16. */
2226
const MIN_OUTPUT_TOKENS = 16;
27+
// Independently implements Pi's byte-idle timeout semantics from http-dispatcher.ts at 6c87d9a02.
28+
// https://github.com/badlogic/pi-mono/blob/6c87d9a02/packages/coding-agent/src/core/http-dispatcher.ts
29+
// Model-only connection pooling. Per-dispatch overrides also override fetch's internal defaults.
30+
let modelDispatcher: EnvHttpProxyAgent | undefined;
31+
function dispatcherFor(timeoutMs: number) {
32+
modelDispatcher ??= new EnvHttpProxyAgent({
33+
allowH2: false,
34+
connect: { autoSelectFamilyAttemptTimeout: 2_000 },
35+
});
36+
return modelDispatcher.compose(
37+
(dispatch) => (options, handler) =>
38+
dispatch({ ...options, headersTimeout: timeoutMs, bodyTimeout: timeoutMs }, handler),
39+
);
40+
}
41+
const IDLE_TIMEOUT_CODES = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
2342
const SAFE_CONNECT_FAILURES = new Set([
2443
"EAI_AGAIN",
2544
"ENOTFOUND",
@@ -133,9 +152,12 @@ export function encodeResponsesRequest(
133152
store: false,
134153
};
135154
if (request.system !== undefined) body.instructions = request.system;
136-
if (request.maxOutputTokens !== undefined) {
137-
body.max_output_tokens = Math.max(request.maxOutputTokens, MIN_OUTPUT_TOKENS);
138-
}
155+
const configuration = fitModelRequest(model, request);
156+
if (configuration.maxOutputTokens < MIN_OUTPUT_TOKENS)
157+
throw new ResponsesCodecError(
158+
`OpenAI Responses needs at least ${MIN_OUTPUT_TOKENS} output tokens; the requested or available ceiling is ${configuration.maxOutputTokens}`,
159+
);
160+
body.max_output_tokens = configuration.maxOutputTokens;
139161
if (request.tools !== undefined && request.tools.length > 0) {
140162
body.tools = request.tools.map((tool) => ({
141163
type: "function",
@@ -354,7 +376,7 @@ export class OpenAiResponsesProvider implements ModelProvider {
354376
private readonly endpoint: ResponsesEndpoint;
355377
private readonly models: readonly ModelInfo[];
356378
private readonly resolveAuth: () => Promise<ResolvedAuth>;
357-
private readonly fetchImpl: typeof fetch;
379+
private readonly fetchImpl: typeof fetch | undefined;
358380

359381
constructor(options: OpenAiResponsesProviderOptions) {
360382
this.id = options.id;
@@ -363,7 +385,7 @@ export class OpenAiResponsesProvider implements ModelProvider {
363385
this.endpoint = options.endpoint;
364386
this.models = options.models;
365387
this.resolveAuth = options.resolveAuth;
366-
this.fetchImpl = options.fetch ?? fetch;
388+
this.fetchImpl = options.fetch;
367389
}
368390

369391
listModels(): Promise<readonly ModelInfo[]> {
@@ -384,7 +406,12 @@ export class OpenAiResponsesProvider implements ModelProvider {
384406
request: ModelRequest,
385407
): AsyncGenerator<ModelStreamEvent, void, undefined> {
386408
let url: string;
387-
let init: RequestInit;
409+
let init: {
410+
method: string;
411+
headers: Record<string, string>;
412+
body: string;
413+
signal?: AbortSignal;
414+
};
388415
try {
389416
const resolved = await this.resolveAuth();
390417
const body = encodeResponsesRequest(
@@ -420,9 +447,15 @@ export class OpenAiResponsesProvider implements ModelProvider {
420447
return;
421448
}
422449

423-
let response: Response;
450+
let response: Pick<Response, "ok" | "status" | "headers" | "body">;
424451
try {
425-
response = await this.fetchImpl(url, init);
452+
response =
453+
this.fetchImpl === undefined
454+
? await modelFetch(url, {
455+
...init,
456+
dispatcher: dispatcherFor(fitModelRequest(model, request).httpIdleTimeoutMs),
457+
})
458+
: await this.fetchImpl(url, init);
426459
} catch (error) {
427460
const code = nestedErrorCode(error);
428461
const safeToRetry = code !== undefined && SAFE_CONNECT_FAILURES.has(code);
@@ -491,11 +524,23 @@ export class OpenAiResponsesProvider implements ModelProvider {
491524
request: ModelRequest,
492525
error: unknown,
493526
code: string,
494-
requestPhase: "before_dispatch" | "streaming" | "unknown",
527+
requestPhase: "before_dispatch" | "awaiting_response" | "streaming" | "unknown",
495528
retryable: boolean,
496529
category: ModelErrorCategory,
497530
): ModelStreamEvent {
498531
if (request.signal?.aborted) return { type: "aborted" };
532+
const transportCode = nestedErrorCode(error);
533+
if (transportCode !== undefined && IDLE_TIMEOUT_CODES.has(transportCode)) {
534+
return {
535+
type: "error",
536+
code: "model_request_idle_timeout",
537+
message: `Provider transport was idle for ${request.httpIdleTimeoutMs ?? 300_000} ms while ${transportCode === "UND_ERR_HEADERS_TIMEOUT" ? "waiting for response headers" : "reading the response body"}`,
538+
retryable: false,
539+
category: "timeout",
540+
requestPhase:
541+
transportCode === "UND_ERR_HEADERS_TIMEOUT" ? "awaiting_response" : "streaming",
542+
};
543+
}
499544
return {
500545
type: "error",
501546
code,

packages/ai/src/provider-port.ts

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@
55
import type {
66
BlobReference,
77
ModelMessage,
8+
ModelRequestConfiguration,
9+
ModelRequestSettings,
810
ModelStreamEvent,
911
ThinkingLevel,
1012
ToolDeclaration,
1113
} from "@axl/protocol";
1214

15+
import { DEFAULT_MODEL_REQUEST_SETTINGS } from "@axl/protocol";
16+
import { fitModelRequest } from "./request-configuration.ts";
17+
1318
import type { ModelProvider } from "./provider.ts";
1419
import { normalizeModelStream } from "./stream.ts";
1520

1621
export interface SessionPortOptions {
1722
readonly modelId: string;
1823
readonly thinkingLevel?: ThinkingLevel;
1924
readonly maxOutputTokens?: number;
25+
readonly requestSettings?: ModelRequestSettings;
2026
readonly readBlob?: (reference: BlobReference) => Promise<Uint8Array>;
2127
}
2228

@@ -27,6 +33,10 @@ interface PortTurnRequest {
2733
readonly maxOutputTokens?: number | undefined;
2834
readonly toolChoice?: "auto" | "required" | "none" | undefined;
2935
readonly signal?: AbortSignal | undefined;
36+
readonly estimatedInputTokens?: number | undefined;
37+
readonly onRequestConfigured?:
38+
| ((configuration: ModelRequestConfiguration) => Promise<void>)
39+
| undefined;
3040
}
3141

3242
/**
@@ -41,19 +51,53 @@ export function modelPortForSession(
4151
return {
4252
stream: (request) =>
4353
normalizeModelStream(
44-
provider.stream({
45-
modelId: options.modelId,
46-
...(request.system === undefined ? {} : { system: request.system }),
47-
messages: request.messages,
48-
tools: request.tools,
49-
...(options.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel }),
50-
...(request.maxOutputTokens === undefined && options.maxOutputTokens === undefined
51-
? {}
52-
: { maxOutputTokens: request.maxOutputTokens ?? options.maxOutputTokens }),
53-
...(request.toolChoice === undefined ? {} : { toolChoice: request.toolChoice }),
54-
...(options.readBlob === undefined ? {} : { readBlob: options.readBlob }),
55-
...(request.signal === undefined ? {} : { signal: request.signal }),
56-
}),
54+
(async function* () {
55+
if (request.signal?.aborted) {
56+
yield { type: "aborted" } as const;
57+
return;
58+
}
59+
const model = (await provider.listModels()).find(
60+
(candidate) => candidate.modelId === options.modelId,
61+
);
62+
if (model === undefined)
63+
throw new Error(`Provider ${provider.id} has no model ${options.modelId}`);
64+
const settings = options.requestSettings ?? DEFAULT_MODEL_REQUEST_SETTINGS;
65+
const maxOutputTokens =
66+
request.maxOutputTokens ??
67+
options.maxOutputTokens ??
68+
settings.maxOutputTokens ??
69+
undefined;
70+
const configuration = fitModelRequest(model, {
71+
messages: request.messages,
72+
tools: request.tools,
73+
...(request.system === undefined ? {} : { system: request.system }),
74+
...(request.estimatedInputTokens === undefined
75+
? {}
76+
: { estimatedInputTokens: request.estimatedInputTokens }),
77+
...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
78+
httpIdleTimeoutMs: settings.httpIdleTimeoutMs,
79+
});
80+
await request.onRequestConfigured?.(configuration);
81+
if (request.signal?.aborted) {
82+
yield { type: "aborted" } as const;
83+
return;
84+
}
85+
yield* provider.stream({
86+
modelId: options.modelId,
87+
...(request.system === undefined ? {} : { system: request.system }),
88+
messages: request.messages,
89+
tools: request.tools,
90+
...(options.thinkingLevel === undefined
91+
? {}
92+
: { thinkingLevel: options.thinkingLevel }),
93+
maxOutputTokens: configuration.maxOutputTokens,
94+
httpIdleTimeoutMs: configuration.httpIdleTimeoutMs,
95+
estimatedInputTokens: configuration.estimatedInputTokens,
96+
...(request.toolChoice === undefined ? {} : { toolChoice: request.toolChoice }),
97+
...(options.readBlob === undefined ? {} : { readBlob: options.readBlob }),
98+
...(request.signal === undefined ? {} : { signal: request.signal }),
99+
});
100+
})(),
57101
request.signal,
58102
),
59103
};

0 commit comments

Comments
 (0)