praxis-agent 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -200,7 +200,8 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
200
200
  imports, memory, skills, commands, agents, hooks, settings, MCP servers,
201
201
  plugins, and append-only `praxis.transcript` JSONL sessions under `~/.praxis`.
202
202
  - **Provider-neutral models** — native Anthropic Messages and OpenAI-compatible
203
- streaming adapters with explicit capability checks and metering controls.
203
+ streaming adapters with explicit capability checks, metering controls, and
204
+ a bounded absolute deadline for every provider attempt.
204
205
 
205
206
  Detailed feature status and executable evidence live in the
206
207
  [parity matrix](https://github.com/Forest-Isle/Praxis/blob/main/docs/PARITY_MATRIX.md),
@@ -47,6 +47,7 @@ import { redactSensitiveText, sensitiveEnvironmentValues, } from './platform/sen
47
47
  import { AnthropicCompatibleProvider } from './providers/anthropic-compatible.js';
48
48
  import { createAnthropicPromptCachePolicyResolver } from './providers/anthropic-prompt-cache.js';
49
49
  import { FallbackModelProvider } from './providers/fallback-provider.js';
50
+ import { DeadlineModelProvider } from './providers/deadline-provider.js';
50
51
  import { OpenAICompatibleProvider } from './providers/openai-compatible.js';
51
52
  import { parseContextEnvironment, parseProviderEnvironment, } from './providers/environment.js';
52
53
  import { ModelPricingRegistry, usageCostUsd } from './core/usage.js';
@@ -107,7 +108,7 @@ function createProviderForModel({ apiKey, environment, provider, context, contro
107
108
  ? { contextWindowTokens: context.contextWindowTokens }
108
109
  : {}),
109
110
  };
110
- return provider.provider === 'anthropic'
111
+ const concrete = provider.provider === 'anthropic'
111
112
  ? new AnthropicCompatibleProvider({
112
113
  ...providerOptions,
113
114
  promptCaching: resolvePromptCachePolicy({
@@ -126,7 +127,9 @@ function createProviderForModel({ apiKey, environment, provider, context, contro
126
127
  ...('anthropicVersion' in provider
127
128
  ? { anthropicVersion: provider.anthropicVersion }
128
129
  : {}),
129
- ...('webSearch' in provider ? { webSearch: provider.webSearch } : {}),
130
+ ...('webSearch' in provider
131
+ ? { webSearch: provider.webSearch }
132
+ : {}),
130
133
  })
131
134
  : new OpenAICompatibleProvider({
132
135
  ...providerOptions,
@@ -142,6 +145,10 @@ function createProviderForModel({ apiKey, environment, provider, context, contro
142
145
  },
143
146
  }),
144
147
  });
148
+ return new DeadlineModelProvider({
149
+ provider: concrete,
150
+ deadlineMs: provider.deadlineMs,
151
+ });
145
152
  };
146
153
  }
147
154
  const HELP = `Praxis — local-first general agent
@@ -0,0 +1,16 @@
1
+ import { type ModelProvider, type ModelRequest, type ModelStreamEvent } from '../core/runtime.js';
2
+ export declare const DEFAULT_PROVIDER_DEADLINE_MS = 90000;
3
+ export interface DeadlineModelProviderOptions {
4
+ provider: ModelProvider;
5
+ deadlineMs?: number;
6
+ }
7
+ /** Bounds each streamed completion while preserving the wrapped provider's API. */
8
+ export declare class DeadlineModelProvider implements ModelProvider {
9
+ private readonly provider;
10
+ private readonly deadlineMs;
11
+ readonly model?: string;
12
+ constructor(options: DeadlineModelProviderOptions);
13
+ get capabilities(): ModelProvider['capabilities'];
14
+ complete(request: ModelRequest): AsyncIterable<ModelStreamEvent>;
15
+ }
16
+ //# sourceMappingURL=deadline-provider.d.ts.map
@@ -0,0 +1,226 @@
1
+ import { ModelProviderError, } from '../core/runtime.js';
2
+ export const DEFAULT_PROVIDER_DEADLINE_MS = 90_000;
3
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
4
+ function deferred() {
5
+ let resolve;
6
+ const promise = new Promise((promiseResolve) => {
7
+ resolve = promiseResolve;
8
+ });
9
+ return { promise, resolve };
10
+ }
11
+ /** Bounds each streamed completion while preserving the wrapped provider's API. */
12
+ export class DeadlineModelProvider {
13
+ provider;
14
+ deadlineMs;
15
+ constructor(options) {
16
+ const deadlineMs = options.deadlineMs === undefined
17
+ ? DEFAULT_PROVIDER_DEADLINE_MS
18
+ : options.deadlineMs;
19
+ if (!Number.isFinite(deadlineMs) ||
20
+ !Number.isInteger(deadlineMs) ||
21
+ deadlineMs <= 0) {
22
+ throw new Error('Provider deadline must be a positive integer');
23
+ }
24
+ this.provider = options.provider;
25
+ this.deadlineMs = deadlineMs;
26
+ Object.defineProperty(this, 'model', {
27
+ configurable: false,
28
+ enumerable: true,
29
+ get: () => this.provider.model,
30
+ });
31
+ }
32
+ get capabilities() {
33
+ return this.provider.capabilities;
34
+ }
35
+ complete(request) {
36
+ let started = false;
37
+ let closed = false;
38
+ let interruption;
39
+ let controller;
40
+ let callerSignal;
41
+ let timer;
42
+ let deadlineStartedAt;
43
+ let underlying;
44
+ let cleanupStarted = false;
45
+ const interruptionDeferred = deferred();
46
+ const interruptionError = () => {
47
+ if (interruption?.kind === 'timeout') {
48
+ return new ModelProviderError('Provider request timed out', {
49
+ kind: 'timeout',
50
+ retryable: true,
51
+ cause: interruption.cause,
52
+ });
53
+ }
54
+ return new ModelProviderError('Provider request cancelled', {
55
+ kind: 'cancelled',
56
+ retryable: false,
57
+ cause: interruption?.cause,
58
+ });
59
+ };
60
+ const bestEffortUnderlyingReturn = () => {
61
+ const iterator = underlying;
62
+ if (!iterator || cleanupStarted)
63
+ return;
64
+ cleanupStarted = true;
65
+ let returnMethod;
66
+ try {
67
+ returnMethod = iterator.return;
68
+ }
69
+ catch {
70
+ return;
71
+ }
72
+ if (!returnMethod)
73
+ return;
74
+ try {
75
+ const result = returnMethod.call(iterator);
76
+ if (result && typeof result.then === 'function') {
77
+ void Promise.resolve(result).catch(() => undefined);
78
+ }
79
+ }
80
+ catch {
81
+ // Cleanup is best effort; preserve the original completion outcome.
82
+ }
83
+ };
84
+ const clearResources = () => {
85
+ if (timer !== undefined)
86
+ clearTimeout(timer);
87
+ timer = undefined;
88
+ callerSignal?.removeEventListener('abort', onCallerAbort);
89
+ };
90
+ const finish = (outcome) => {
91
+ if (interruption !== undefined)
92
+ return;
93
+ interruption = outcome;
94
+ closed = true;
95
+ interruptionDeferred.resolve(outcome);
96
+ controller?.abort(outcome.cause);
97
+ clearResources();
98
+ if (outcome.kind !== 'return')
99
+ bestEffortUnderlyingReturn();
100
+ };
101
+ const onCallerAbort = () => finish({ kind: 'cancelled', cause: callerSignal?.reason });
102
+ const timeoutCause = new Error('Provider request timed out');
103
+ timeoutCause.name = 'TimeoutError';
104
+ const scheduleDeadline = () => {
105
+ if (deadlineStartedAt === undefined || closed)
106
+ return;
107
+ const elapsedMs = Math.max(0, performance.now() - deadlineStartedAt);
108
+ const remainingMs = this.deadlineMs - elapsedMs;
109
+ if (remainingMs <= 0) {
110
+ finish({ kind: 'timeout', cause: timeoutCause });
111
+ return;
112
+ }
113
+ timer = setTimeout(scheduleDeadline, Math.min(remainingMs, MAX_TIMER_DELAY_MS));
114
+ };
115
+ const start = () => {
116
+ if (started || closed)
117
+ return;
118
+ started = true;
119
+ controller = new AbortController();
120
+ callerSignal = request.signal;
121
+ if (callerSignal?.aborted)
122
+ onCallerAbort();
123
+ else
124
+ callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
125
+ if (interruption !== undefined)
126
+ return;
127
+ deadlineStartedAt = performance.now();
128
+ scheduleDeadline();
129
+ const iterable = this.provider.complete({
130
+ ...request,
131
+ signal: controller.signal,
132
+ });
133
+ underlying = iterable[Symbol.asyncIterator]();
134
+ if (interruption !== undefined)
135
+ bestEffortUnderlyingReturn();
136
+ };
137
+ const next = async () => {
138
+ try {
139
+ start();
140
+ }
141
+ catch (error) {
142
+ if (interruption !== undefined)
143
+ throw interruptionError();
144
+ closed = true;
145
+ clearResources();
146
+ bestEffortUnderlyingReturn();
147
+ throw error;
148
+ }
149
+ if (interruption?.kind === 'return')
150
+ return { done: true, value: undefined };
151
+ if (interruption !== undefined)
152
+ throw interruptionError();
153
+ if (closed || !underlying)
154
+ return { done: true, value: undefined };
155
+ let nextPromise;
156
+ try {
157
+ const result = underlying.next();
158
+ nextPromise = Promise.resolve(result);
159
+ }
160
+ catch (error) {
161
+ if (interruption !== undefined)
162
+ throw interruptionError();
163
+ closed = true;
164
+ clearResources();
165
+ bestEffortUnderlyingReturn();
166
+ throw error;
167
+ }
168
+ if (interruption !== undefined) {
169
+ void nextPromise.catch(() => undefined);
170
+ throw interruptionError();
171
+ }
172
+ const result = await Promise.race([
173
+ nextPromise.then((value) => ({ type: 'result', value }), (error) => ({ type: 'error', error })),
174
+ interruptionDeferred.promise.then((outcome) => ({
175
+ type: 'interruption',
176
+ outcome,
177
+ })),
178
+ ]);
179
+ const observedInterruption = interruption ??
180
+ (result.type === 'interruption' ? result.outcome : undefined);
181
+ if (observedInterruption !== undefined) {
182
+ if (result.type === 'result')
183
+ void nextPromise.catch(() => undefined);
184
+ if (observedInterruption.kind === 'return')
185
+ return { done: true, value: undefined };
186
+ throw interruptionError();
187
+ }
188
+ if (result.type === 'error') {
189
+ closed = true;
190
+ clearResources();
191
+ bestEffortUnderlyingReturn();
192
+ throw result.error;
193
+ }
194
+ if (result.type === 'interruption') {
195
+ if (result.outcome.kind === 'return')
196
+ return { done: true, value: undefined };
197
+ throw interruptionError();
198
+ }
199
+ if (result.value.done) {
200
+ closed = true;
201
+ clearResources();
202
+ }
203
+ return result.value;
204
+ };
205
+ const returnFromConsumer = async () => {
206
+ if (!started) {
207
+ closed = true;
208
+ return { done: true, value: undefined };
209
+ }
210
+ if (!closed) {
211
+ finish({ kind: 'return', cause: undefined });
212
+ bestEffortUnderlyingReturn();
213
+ }
214
+ return { done: true, value: undefined };
215
+ };
216
+ const result = {
217
+ [Symbol.asyncIterator]() {
218
+ return result;
219
+ },
220
+ next,
221
+ return: returnFromConsumer,
222
+ };
223
+ return result;
224
+ }
225
+ }
226
+ //# sourceMappingURL=deadline-provider.js.map
@@ -1,6 +1,7 @@
1
1
  export interface ProviderEnvironment {
2
2
  provider: 'openai' | 'anthropic';
3
3
  baseUrl: string;
4
+ deadlineMs: number;
4
5
  maxOutputTokens?: number;
5
6
  anthropicVersion?: string;
6
7
  webSearch?: boolean;
@@ -1,9 +1,20 @@
1
1
  import { createAnthropicPromptCachePolicyResolver } from './anthropic-prompt-cache.js';
2
+ import { DEFAULT_PROVIDER_DEADLINE_MS } from './deadline-provider.js';
2
3
  export function parseProviderEnvironment(environment) {
3
4
  const provider = environment.PRAXIS_PROVIDER ?? 'openai';
4
5
  if (provider !== 'openai' && provider !== 'anthropic') {
5
6
  throw new Error('PRAXIS_PROVIDER must be openai or anthropic');
6
7
  }
8
+ const rawDeadlineMs = environment.PRAXIS_PROVIDER_DEADLINE_MS;
9
+ if (rawDeadlineMs !== undefined &&
10
+ (!/^\d+$/.test(rawDeadlineMs) ||
11
+ Number(rawDeadlineMs) <= 0 ||
12
+ !Number.isSafeInteger(Number(rawDeadlineMs)))) {
13
+ throw new Error('PRAXIS_PROVIDER_DEADLINE_MS must be a positive safe integer');
14
+ }
15
+ const deadlineMs = rawDeadlineMs === undefined
16
+ ? DEFAULT_PROVIDER_DEADLINE_MS
17
+ : Number(rawDeadlineMs);
7
18
  const maxOutputTokens = environment.PRAXIS_MAX_OUTPUT_TOKENS;
8
19
  if (maxOutputTokens !== undefined &&
9
20
  (!/^\d+$/.test(maxOutputTokens) ||
@@ -47,6 +58,7 @@ export function parseProviderEnvironment(environment) {
47
58
  (provider === 'anthropic'
48
59
  ? 'https://api.anthropic.com/v1'
49
60
  : 'https://api.openai.com/v1'),
61
+ deadlineMs,
50
62
  ...(maxOutputTokens === undefined
51
63
  ? {}
52
64
  : { maxOutputTokens: Number(maxOutputTokens) }),
@@ -6,6 +6,38 @@ function retryable(error) {
6
6
  error.retryable &&
7
7
  modelProviderErrorKind(error) !== 'prompt_too_long');
8
8
  }
9
+ function cancellationError(signal) {
10
+ return new ModelProviderError('Provider request cancelled', {
11
+ kind: 'cancelled',
12
+ retryable: false,
13
+ cause: signal.reason,
14
+ });
15
+ }
16
+ function throwIfAborted(signal) {
17
+ if (signal?.aborted)
18
+ throw cancellationError(signal);
19
+ }
20
+ async function waitForRetry(delayMs, signal) {
21
+ throwIfAborted(signal);
22
+ if (delayMs <= 0)
23
+ return;
24
+ await new Promise((resolve, reject) => {
25
+ let settled = false;
26
+ const finish = (result) => {
27
+ if (settled)
28
+ return;
29
+ settled = true;
30
+ clearTimeout(timer);
31
+ signal?.removeEventListener('abort', onAbort);
32
+ result();
33
+ };
34
+ const onAbort = () => finish(() => reject(cancellationError(signal)));
35
+ const timer = setTimeout(() => finish(resolve), delayMs);
36
+ signal?.addEventListener('abort', onAbort, { once: true });
37
+ if (signal?.aborted)
38
+ onAbort();
39
+ });
40
+ }
9
41
  /** Routes each complete request through Claude's retry-then-fallback policy. */
10
42
  export class FallbackModelProvider {
11
43
  active;
@@ -30,6 +62,7 @@ export class FallbackModelProvider {
30
62
  let lastError;
31
63
  for (const provider of this.providers()) {
32
64
  for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_MODEL; attempt += 1) {
65
+ throwIfAborted(request.signal);
33
66
  this.active = provider;
34
67
  const attemptStartedAt = performance.now();
35
68
  try {
@@ -73,6 +106,7 @@ export class FallbackModelProvider {
73
106
  return;
74
107
  }
75
108
  catch (error) {
109
+ throwIfAborted(request.signal);
76
110
  lastError = error;
77
111
  if (!retryable(error))
78
112
  throw error;
@@ -89,8 +123,7 @@ export class FallbackModelProvider {
89
123
  error: modelProviderErrorKind(error),
90
124
  };
91
125
  }
92
- if (delay > 0)
93
- await new Promise((resolve) => setTimeout(resolve, delay));
126
+ await waitForRetry(delay, request.signal);
94
127
  }
95
128
  }
96
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",