@hunterzhu/pulse-adapters 0.5.1 → 0.7.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.
Files changed (36) hide show
  1. package/dist/documents/docx.d.ts +2 -0
  2. package/dist/documents/docx.js +45 -0
  3. package/dist/documents/tools.d.ts +12 -0
  4. package/dist/documents/tools.js +23 -8
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/mcp/stdio.js +12 -1
  8. package/dist/native/LICENSE +201 -0
  9. package/dist/native/srt-win.exe +0 -0
  10. package/dist/native/srt-win.json +11 -0
  11. package/dist/providers/anthropic.d.ts +1 -0
  12. package/dist/providers/anthropic.js +7 -50
  13. package/dist/providers/canonical/lowering.js +12 -0
  14. package/dist/providers/canonical/mappers/anthropic.js +4 -3
  15. package/dist/providers/canonical/mappers/openai.js +1 -1
  16. package/dist/providers/normalize.d.ts +6 -1
  17. package/dist/providers/normalize.js +29 -3
  18. package/dist/providers/openai-compat.d.ts +2 -0
  19. package/dist/providers/openai-compat.js +10 -1
  20. package/dist/providers/runtime-executor.d.ts +8 -1
  21. package/dist/providers/runtime-executor.js +55 -10
  22. package/dist/providers/types.d.ts +2 -0
  23. package/dist/tools/installation-dependencies.d.ts +4 -0
  24. package/dist/tools/installation-dependencies.js +59 -0
  25. package/dist/tools/registry.js +5 -2
  26. package/dist/tools/sandbox-initialization.d.ts +1 -1
  27. package/dist/tools/sandbox-initialization.js +63 -7
  28. package/dist/tools/shell-output.d.ts +23 -0
  29. package/dist/tools/shell-output.js +82 -0
  30. package/dist/tools/shell.d.ts +6 -0
  31. package/dist/tools/shell.js +62 -24
  32. package/dist/tools/windows-read-policy.d.ts +4 -0
  33. package/dist/tools/windows-read-policy.js +11 -1
  34. package/dist/tools/windows-srt-path.d.ts +2 -0
  35. package/dist/tools/windows-srt-path.js +18 -0
  36. package/package.json +4 -3
@@ -39,8 +39,33 @@ function summarizeProviderError(raw) {
39
39
  function truncateProviderDetail(value) {
40
40
  return value.length <= 500 ? value : `${value.slice(0, 497)}...`;
41
41
  }
42
+ const NETWORK_CAUSE_CODES = new Set([
43
+ 'ENOTFOUND',
44
+ 'EAI_AGAIN',
45
+ 'ECONNRESET',
46
+ 'ECONNREFUSED',
47
+ 'ETIMEDOUT',
48
+ 'UND_ERR_CONNECT_TIMEOUT',
49
+ ]);
50
+ /**
51
+ * Keep the stable PROVIDER_NETWORK_ERROR code and retryable=true, appending only
52
+ * whitelisted low-level cause codes. Arbitrary codes, messages, URLs and credentials
53
+ * on the cause chain are ignored.
54
+ */
42
55
  export function providerNetworkError(cause) {
43
- return Object.assign(new Error('PROVIDER_NETWORK_ERROR'), { code: 'PROVIDER_NETWORK_ERROR', retryable: true, cause });
56
+ const reasons = [];
57
+ let node = cause;
58
+ for (let depth = 0; depth < 4 && node !== null && typeof node === 'object'; depth += 1) {
59
+ const code = node.code;
60
+ if (typeof code === 'string' && NETWORK_CAUSE_CODES.has(code) && !reasons.includes(code))
61
+ reasons.push(code);
62
+ const name = node.name;
63
+ if ((name === 'TimeoutError' || name === 'AbortError') && !reasons.includes(name))
64
+ reasons.push(name);
65
+ node = node.cause;
66
+ }
67
+ const message = reasons.length > 0 ? `PROVIDER_NETWORK_ERROR (${reasons.join(', ')})` : 'PROVIDER_NETWORK_ERROR';
68
+ return Object.assign(new Error(message), { code: 'PROVIDER_NETWORK_ERROR', retryable: true, cause });
44
69
  }
45
70
  export function providerResponseError(detail) {
46
71
  return Object.assign(new Error(`PROVIDER_RESPONSE_INVALID: ${detail}`), { code: 'PROVIDER_RESPONSE_INVALID', retryable: true });
@@ -402,7 +427,7 @@ const DSML_INVOKE_PATTERN = /<\uFF5C+DSML\uFF5C+\s*invoke\s+name="([^"]+)"\s*\/?
402
427
  const DSML_PARAMETER_OPEN = /<\uFF5C+DSML\uFF5C+\s*parameter\b/;
403
428
  const DSML_PARAMETER_CLOSE = /<\/\uFF5C+DSML\uFF5C+\s*parameter>/;
404
429
  const DSML_PARAMETER_PATTERN = /<\uFF5C+DSML\uFF5C+\s*parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>([\s\S]*?)<\/\uFF5C+DSML\uFF5C+\s*parameter>/;
405
- export function normalizeAnthropicResponse(response) {
430
+ export function normalizeAnthropicResponse(response, aliases = new Map()) {
406
431
  const root = providerRecord(response, 'Anthropic response');
407
432
  if (!Array.isArray(root.content))
408
433
  throw providerResponseError('Anthropic response content must be an array');
@@ -411,7 +436,8 @@ export function normalizeAnthropicResponse(response) {
411
436
  const toolBlocks = blocks.filter((block) => providerRecord(block, 'Anthropic content block').type === 'tool_use');
412
437
  const toolCalls = (root.stop_reason === 'max_tokens' ? [] : toolBlocks).map((block, index) => {
413
438
  const value = providerRecord(block, 'Anthropic tool block');
414
- return { toolCallId: `pulse-tool-${index + 1}`, name: requiredProviderString(value.name, 'Anthropic tool name'), input: parseJson(value.input) };
439
+ const providerName = requiredProviderString(value.name, 'Anthropic tool name');
440
+ return { toolCallId: `pulse-tool-${index + 1}`, name: aliases.get(providerName) ?? providerName, input: parseJson(value.input), ...(typeof value.id === 'string' ? { providerToolCallId: value.id, providerToolName: providerName, providerToolArguments: JSON.stringify(value.input) } : {}) };
415
441
  });
416
442
  const refusalBlock = blocks.find((block) => block.type === 'refusal' && typeof block.text === 'string');
417
443
  const refusal = refusalBlock?.text;
@@ -11,9 +11,11 @@ export declare class OpenAICompatibleAdapter implements ProviderAdapter {
11
11
  request: LLMRequestProjection;
12
12
  signal: AbortSignal;
13
13
  onObservation?: (chunk: string) => void;
14
+ onReasoning?: (chunk: string) => void;
14
15
  model?: string;
15
16
  outputSchema?: JsonValue;
16
17
  maxOutputTokens?: number;
18
+ beforeRetryAttempt?: () => Promise<void>;
17
19
  }): Promise<LLMResult>;
18
20
  }
19
21
  export declare function toOpenAIMessages(request: LLMRequestProjection, provider?: string): Array<Record<string, unknown>>;
@@ -9,7 +9,13 @@ export class OpenAICompatibleAdapter {
9
9
  constructor(id, config) {
10
10
  this.id = id;
11
11
  this.config = config;
12
- this.baseURL = config.baseURL ?? 'https://api.openai.com/v1';
12
+ let baseURL = config.baseURL ?? (config.provider === 'ollama' ? 'http://127.0.0.1:11434/v1' : 'https://api.openai.com/v1');
13
+ if (config.provider === 'ollama') {
14
+ const url = new URL(baseURL);
15
+ if (url.pathname === '/' && !url.search && !url.hash)
16
+ baseURL = baseURL.replace(/\/$/, '') + '/v1';
17
+ }
18
+ this.baseURL = baseURL;
13
19
  }
14
20
  async executeAttempt(params) {
15
21
  const streaming = params.onObservation !== undefined;
@@ -37,6 +43,8 @@ export class OpenAICompatibleAdapter {
37
43
  for (const ev of streamEvents) {
38
44
  if (ev.type === 'text_delta')
39
45
  params.onObservation?.(ev.text);
46
+ if (ev.type === 'reasoning_delta')
47
+ params.onReasoning?.(ev.text);
40
48
  if (ev.type === 'refusal_delta')
41
49
  params.onObservation?.(ev.refusal);
42
50
  }
@@ -75,6 +83,7 @@ export class OpenAICompatibleAdapter {
75
83
  throw Object.assign(new Error('Provider request was cancelled.'), { code: 'PROVIDER_REQUEST_CANCELLED', retryable: false, cause });
76
84
  if (sentReasoning && this.probe.reasoningParameterRejected(cause)) {
77
85
  this.probe.markReasoningUnsupported(this.config.provider, canonical.model);
86
+ await params.beforeRetryAttempt?.();
78
87
  return this.executeAttempt(params);
79
88
  }
80
89
  if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string' && 'retryable' in cause && typeof cause.retryable === 'boolean')
@@ -1,9 +1,16 @@
1
- import type { EffectExecutor, ModelRouteRequirements, ModelRouter } from '@hunterzhu/pulse-runtime';
1
+ import type { EffectExecutor, LLMRequestProjection, LLMResult, ModelRouteRequirements, ModelRouter } from '@hunterzhu/pulse-runtime';
2
2
  import type { ProviderAdapter } from './types.js';
3
3
  export declare function createModelEffectExecutor(config: {
4
+ resolveImage?: (ref: string, agentId: string, privacy: LLMRequestProjection['privacy']) => string | Promise<string>;
4
5
  router: ModelRouter;
5
6
  providers: ReadonlyMap<string, ProviderAdapter>;
6
7
  requirements?: ModelRouteRequirements;
7
8
  maxConcurrentByProvider?: Readonly<Record<string, number>>;
8
9
  maxConcurrentByModel?: Readonly<Record<string, number>>;
10
+ modelBudget?: () => {
11
+ limit: number;
12
+ used: number;
13
+ };
14
+ beforeProviderAttempt?: (purpose?: string) => Promise<void>;
15
+ afterProviderAttempt?: (usage: LLMResult['usage'], receiptId?: string) => Promise<void>;
9
16
  }): EffectExecutor;
@@ -1,4 +1,5 @@
1
- import { assignRuntimeToolCallIds, ModelFallbackController, OutputValidationError, estimateProjectionTokens, stableSerialize, validateAdapterResult, validateJsonSchema, modelFallbackError, runtimeErrorFromCause } from '@hunterzhu/pulse-runtime';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { assignRuntimeToolCallIds, contentHash, ModelFallbackController, OutputValidationError, estimateProjectionTokens, stableSerialize, validateAdapterResult, validateJsonSchema, modelFallbackError, runtimeErrorFromCause } from '@hunterzhu/pulse-runtime';
2
3
  class AsyncSlot {
3
4
  limit;
4
5
  active = 0;
@@ -142,11 +143,13 @@ export function createModelEffectExecutor(config) {
142
143
  if (effect.kind !== 'llm')
143
144
  throw new Error(`UNSUPPORTED_EFFECT_KIND:${effect.kind}`);
144
145
  const input = effect.input && typeof effect.input === 'object' && !Array.isArray(effect.input) ? effect.input : {};
145
- const task = input.task;
146
+ const task = input.request?.images?.length ? 'vision' : input.task;
146
147
  const request = input.request;
147
148
  if (typeof task !== 'string' || !request || typeof request !== 'object' || Array.isArray(request))
148
149
  throw new Error('INVALID_LLM_EFFECT_INPUT');
149
150
  const projection = request;
151
+ const purpose = effect.key?.startsWith('progress-review') ? 'progress-review' : effect.key === 'safety-review' ? 'approval' : effect.key?.startsWith('verify-task') ? 'final-verification' : effect.key?.startsWith('verify-stage') || task === 'verify' ? 'verification' : task === 'plan' ? 'planning' : 'work';
152
+ let providerAttempts = 0;
150
153
  const observations = [];
151
154
  const usage = new Map();
152
155
  const slotWaitMs = new Map();
@@ -157,7 +160,7 @@ export function createModelEffectExecutor(config) {
157
160
  const structuredSchema = structuredRequirement && typeof structuredRequirement === 'object' && !Array.isArray(structuredRequirement) ? structuredRequirement.schema : undefined;
158
161
  if (structuredSchema !== undefined && (input.outputSchema === undefined || stableSerialize(structuredSchema) !== stableSerialize(input.outputSchema)))
159
162
  return { value: null, status: 'failed', executionState: 'failed', privacy: projection.privacy, error: { code: 'STRUCTURED_OUTPUT_CONTRACT_MISMATCH', message: 'requirements.structuredOutput.schema must equal outputSchema.' } };
160
- const routeRequirements = { ...config.requirements, ...(typeof dynamicRequirements.toolCalling === 'boolean' ? { toolCalling: dynamicRequirements.toolCalling } : {}), ...(typeof dynamicRequirements.structuredOutput === 'boolean' ? { structuredOutput: dynamicRequirements.structuredOutput } : structuredSchema === undefined ? {} : { structuredOutput: true }), ...(dynamicRequirements.reasoning === 'low' || dynamicRequirements.reasoning === 'medium' || dynamicRequirements.reasoning === 'high' ? { reasoning: dynamicRequirements.reasoning } : {}), ...(typeof dynamicRequirements.maxOutputTokens === 'number' ? { maxOutputTokens: dynamicRequirements.maxOutputTokens } : {}), ...(typeof dynamicRequirements.contextSize === 'number' ? { contextSize: dynamicRequirements.contextSize } : {}) };
163
+ const routeRequirements = { ...(projection.images?.length ? { imageInput: true } : {}), ...config.requirements, ...(typeof dynamicRequirements.toolCalling === 'boolean' ? { toolCalling: dynamicRequirements.toolCalling } : {}), ...(typeof dynamicRequirements.structuredOutput === 'boolean' ? { structuredOutput: dynamicRequirements.structuredOutput } : structuredSchema === undefined ? {} : { structuredOutput: true }), ...(dynamicRequirements.reasoning === 'low' || dynamicRequirements.reasoning === 'medium' || dynamicRequirements.reasoning === 'high' ? { reasoning: dynamicRequirements.reasoning } : {}), ...(typeof dynamicRequirements.maxOutputTokens === 'number' ? { maxOutputTokens: dynamicRequirements.maxOutputTokens } : {}), ...(typeof dynamicRequirements.contextSize === 'number' ? { contextSize: dynamicRequirements.contextSize } : {}) };
161
164
  const routeDiagnostics = config.router.diagnostics(task, projection.privacy, routeRequirements, estimateProjectionTokens(projection) + (typeof routeRequirements.maxOutputTokens === 'number' ? routeRequirements.maxOutputTokens : 0));
162
165
  const candidates = config.router.routeProjection(task, projection, routeRequirements);
163
166
  const attemptNo = Math.max(1, effect.attemptNo);
@@ -193,6 +196,7 @@ export function createModelEffectExecutor(config) {
193
196
  }
194
197
  slotWaitMs.set(attempt.attemptId, Math.max(0, Date.now() - slotStartedAt));
195
198
  const releases = [providerRelease, modelRelease];
199
+ let providerStartedAt;
196
200
  let feedbackRecorded = false;
197
201
  const recordFeedback = (outcome, quality) => {
198
202
  if (feedbackRecorded)
@@ -202,15 +206,47 @@ export function createModelEffectExecutor(config) {
202
206
  };
203
207
  try {
204
208
  const startedAt = Date.now();
209
+ const usageReceiptId = randomUUID();
210
+ const streamAttempt = { no: attemptNo, emitted: false };
205
211
  const onObservation = (chunk) => {
212
+ streamAttempt.emitted = true;
206
213
  const observation = { type: 'chunk', data: chunk };
207
214
  if (emitObservation)
208
215
  emitObservation(observation);
209
216
  else
210
217
  observations.push(observation);
211
218
  };
212
- const output = assignRuntimeToolCallIds(validateAdapterResult(await provider.executeAttempt({ request: projection, signal, model: attempt.candidate.id, ...(input.outputSchema === undefined ? {} : { outputSchema: input.outputSchema }), ...(typeof routeRequirements.maxOutputTokens === 'number' ? { maxOutputTokens: routeRequirements.maxOutputTokens } : {}), onObservation })), effect.id);
213
- const measuredUsage = { ...(output.usage ?? {}), visibleOutputChars: output.text.length, latencyMs: output.usage?.latencyMs ?? Math.max(0, Date.now() - startedAt), ...(output.usage?.uncachedInputTokens === undefined && output.usage?.inputTokens !== undefined && output.usage.cachedInputTokens !== undefined ? { uncachedInputTokens: Math.max(0, output.usage.inputTokens - output.usage.cachedInputTokens) } : {}) };
219
+ const onReasoning = emitObservation === undefined ? undefined : (chunk) => {
220
+ streamAttempt.emitted = true;
221
+ emitObservation({ type: 'chunk', data: { kind: 'reasoning', text: chunk, task, modelId: attempt.candidate.id, providerId: attempt.candidate.providerId, attemptNo: streamAttempt.no } });
222
+ };
223
+ emitObservation?.({ type: 'trace', data: { kind: 'llm.attempt', task, purpose, modelId: attempt.candidate.id, providerId: attempt.candidate.providerId, attemptNo, fallback: attemptNo > 1 } });
224
+ const images = projection.images === undefined ? undefined : await Promise.all(projection.images.map(async (image) => {
225
+ if (image.data)
226
+ return image;
227
+ if (!projection.contextSpec.artifactRefs?.includes(image.ref) || !config.resolveImage)
228
+ throw Object.assign(new Error('IMAGE_RESOLVER_REQUIRED'), { retryable: false });
229
+ return { ...image, data: await config.resolveImage(image.ref, effect.agentId, projection.privacy) };
230
+ }));
231
+ const hydrated = images === undefined ? projection : { ...projection, images };
232
+ signal.throwIfAborted();
233
+ await config.beforeProviderAttempt?.(purpose);
234
+ providerStartedAt = Date.now();
235
+ providerAttempts++;
236
+ const budget = config.modelBudget?.();
237
+ const blocks = budget ? [...hydrated.blocks, { kind: 'policy', content: `Host model-call ledger: limit=${budget.limit}, used=${budget.used} (including this request), remaining=${Math.max(0, budget.limit - budget.used)}. This is shared by planning, tools' approval reviews, retries, compaction and final verification. Reserve at least two requests for verification. Produce an incremental usable deliverable now; do not spend the remaining allowance on discovery or start another run.` }] : hydrated.blocks;
238
+ const budgeted = budget ? { ...hydrated, blocks, projectionHash: contentHash(blocks) } : hydrated;
239
+ const output = assignRuntimeToolCallIds(validateAdapterResult(await provider.executeAttempt({ request: budgeted, signal, model: attempt.candidate.id, ...(input.outputSchema === undefined ? {} : { outputSchema: input.outputSchema }), ...(typeof routeRequirements.maxOutputTokens === 'number' ? { maxOutputTokens: routeRequirements.maxOutputTokens } : {}), onObservation, ...(onReasoning === undefined ? {} : { onReasoning }), ...(config.beforeProviderAttempt === undefined ? {} : { beforeRetryAttempt: async () => {
240
+ await config.beforeProviderAttempt(purpose);
241
+ providerAttempts++;
242
+ if (!streamAttempt.emitted)
243
+ return;
244
+ streamAttempt.no += 1;
245
+ streamAttempt.emitted = false;
246
+ emitObservation?.({ type: 'trace', data: { kind: 'llm.attempt', task, purpose, modelId: attempt.candidate.id, providerId: attempt.candidate.providerId, attemptNo: streamAttempt.no, fallback: true } });
247
+ } }) })), effect.id);
248
+ const measuredUsage = { ...(output.usage ?? {}), outputTruncated: output.finishReason === 'length', visibleOutputChars: output.text.length, latencyMs: output.usage?.latencyMs ?? Math.max(0, Date.now() - startedAt), ...(output.usage?.uncachedInputTokens === undefined && output.usage?.inputTokens !== undefined && output.usage.cachedInputTokens !== undefined ? { uncachedInputTokens: Math.max(0, output.usage.inputTokens - output.usage.cachedInputTokens) } : {}) };
249
+ await config.afterProviderAttempt?.(measuredUsage, usageReceiptId);
214
250
  usage.set(attempt.attemptId, measuredUsage);
215
251
  if (output.finishReason === 'length' && input.outputSchema !== undefined)
216
252
  throw Object.assign(new OutputValidationError('adapter', 'OUTPUT_TRUNCATED', 'Model output reached its token limit; incomplete output was rejected.', { outputTokens: output.usage?.outputTokens ?? null, reasoningTokens: output.usage?.reasoningTokens ?? null, visibleOutputChars: output.text.length, maxOutputTokens: routeRequirements.maxOutputTokens ?? config.requirements?.maxOutputTokens ?? null }), { retryable: false });
@@ -234,7 +270,16 @@ export function createModelEffectExecutor(config) {
234
270
  return output;
235
271
  }
236
272
  catch (cause) {
237
- recordFeedback('failed', 0);
273
+ // Transport failure still consumed time. Preserve measured timing, but
274
+ // never invent token counts or costs when the provider returned none.
275
+ if (providerStartedAt !== undefined) {
276
+ const measured = usage.get(attempt.attemptId) ?? { latencyMs: Math.max(1, Date.now() - providerStartedAt) };
277
+ usage.set(attempt.attemptId, measured);
278
+ usage.set(effect.attemptId, measured);
279
+ }
280
+ const stopped = cause && typeof cause === 'object' && 'code' in cause && /^(?:TASK_MODEL_CALL_BUDGET_|AI_APPROVAL_DENIED|APPROVAL_DENIED|TOOL_CANCELLED)/.test(String(cause.code));
281
+ if (!stopped)
282
+ recordFeedback('failed', 0);
238
283
  if (signal.aborted)
239
284
  throw modelFallbackError({ retryable: false, localClosed: true, sideEffectState: 'none', cause });
240
285
  const retryable = cause && typeof cause === 'object' && 'retryable' in cause && typeof cause.retryable === 'boolean' ? cause.retryable : true;
@@ -264,13 +309,13 @@ export function createModelEffectExecutor(config) {
264
309
  return { result: { text: '', toolCalls: [], finishReason: 'error' }, candidate, attempts: [{ effectId: effect.id, attemptId: effect.attemptId, attemptNo, candidate }], failed: { ...error, ...(canFallback && fallbackError?.retryable !== false && error.retryable !== false ? { retryable: true } : {}) } };
265
310
  });
266
311
  if ('schemaRejected' in result)
267
- return { value: null, status: 'failed', executionState: 'failed', privacy: projection.privacy, error: { code: 'OUTPUT_SCHEMA_VIOLATION', message: 'Provider output did not match the declared schema.', ...(canFallback ? { retryable: true } : {}) }, metadata: candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics), rejectedOutput: { value: result.schemaRejected, privacy: projection.privacy, derivedFrom: [...(effect.derivedFrom ?? [])] } };
312
+ return { value: null, status: 'failed', executionState: 'failed', privacy: projection.privacy, error: { code: 'OUTPUT_SCHEMA_VIOLATION', message: 'Provider output did not match the declared schema.', ...(canFallback ? { retryable: true } : {}) }, metadata: { ...candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics), providerAttempts }, rejectedOutput: { value: result.schemaRejected, privacy: projection.privacy, derivedFrom: [...(effect.derivedFrom ?? [])] } };
268
313
  if ('refused' in result)
269
- return { value: null, status: 'failed', executionState: 'failed', privacy: projection.privacy, error: { code: 'MODEL_REFUSAL', message: result.result.refusal ?? 'Provider refused the request.', ...(result.retryable ? { retryable: true } : {}) }, metadata: candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics) };
314
+ return { value: null, status: 'failed', executionState: 'failed', privacy: projection.privacy, error: { code: 'MODEL_REFUSAL', message: result.result.refusal ?? 'Provider refused the request.', ...(result.retryable ? { retryable: true } : {}) }, metadata: { ...candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics), providerAttempts } };
270
315
  if ('failed' in result)
271
- return { value: null, status: 'failed', executionState: 'failed', privacy: projection.privacy, error: result.failed, metadata: candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics) };
316
+ return { value: null, status: 'failed', executionState: 'failed', privacy: projection.privacy, error: result.failed, metadata: { ...candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics), providerAttempts } };
272
317
  const modelValue = input.outputSchema !== undefined || typeof input.schema === 'string' ? (result.result.structured ?? result.result.text) : result.result;
273
318
  const value = toJson(modelValue);
274
- return { value, privacy: projection.privacy, sideEffectState: 'none', executionState: 'succeeded', metadata: candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics), ...(observations.length ? { observations } : {}) };
319
+ return { value, privacy: projection.privacy, sideEffectState: 'none', executionState: 'succeeded', metadata: { ...candidateMetadata(result.candidate, result.attempts, usage, slotWaitMs, routeDiagnostics), providerAttempts }, ...(observations.length ? { observations } : {}) };
275
320
  };
276
321
  }
@@ -12,9 +12,11 @@ export interface ProviderAdapter {
12
12
  request: LLMRequestProjection;
13
13
  signal: AbortSignal;
14
14
  onObservation?: (chunk: string) => void;
15
+ onReasoning?: (chunk: string) => void;
15
16
  model?: string;
16
17
  outputSchema?: JsonValue;
17
18
  maxOutputTokens?: number;
19
+ beforeRetryAttempt?: () => Promise<void>;
18
20
  }): Promise<LLMResult>;
19
21
  }
20
22
  export interface ProviderPresetConfig {
@@ -0,0 +1,4 @@
1
+ /** Host workspaces and linked aliases need dependencies; unrelated scripts do not. */
2
+ export declare function workspaceUsesInstallationDependencies(workspace: string, roots: string[]): Promise<boolean>;
3
+ /** Host installation only. Never resolve grants from model argv or workspace links. */
4
+ export declare function installationDependencyRoots(): Promise<string[]>;
@@ -0,0 +1,59 @@
1
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, join, parse, relative, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /** Host workspaces and linked aliases need dependencies; unrelated scripts do not. */
6
+ export async function workspaceUsesInstallationDependencies(workspace, roots) {
7
+ const contains = (root, path) => {
8
+ const child = relative(root, path);
9
+ return child === '' || child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child);
10
+ };
11
+ if (roots.some(root => contains(dirname(root), workspace)))
12
+ return true;
13
+ const modules = await realpath(join(workspace, 'node_modules')).catch(() => undefined);
14
+ if (!modules)
15
+ return false;
16
+ if (roots.some(root => contains(root, modules)))
17
+ return true;
18
+ // An ordinary project can link individual packages rather than its entire
19
+ // node_modules directory. Inspect only metadata inside that workspace tree.
20
+ if (!contains(workspace, modules))
21
+ return false;
22
+ for (const entry of await readdir(modules, { withFileTypes: true })) {
23
+ const path = join(modules, entry.name);
24
+ const target = await realpath(path).catch(() => undefined);
25
+ if (target && roots.some(root => contains(root, target)))
26
+ return true;
27
+ if (entry.name.startsWith('@') && entry.isDirectory() && target && contains(workspace, target)) {
28
+ for (const name of await readdir(path)) {
29
+ const scoped = await realpath(join(path, name)).catch(() => undefined);
30
+ if (scoped && roots.some(root => contains(root, scoped)))
31
+ return true;
32
+ }
33
+ }
34
+ }
35
+ return false;
36
+ }
37
+ /** Host installation only. Never resolve grants from model argv or workspace links. */
38
+ export async function installationDependencyRoots() {
39
+ const roots = new Set();
40
+ let directory = dirname(fileURLToPath(import.meta.url));
41
+ for (let depth = 0; depth < 12 && directory !== homedir() && directory !== parse(directory).root; depth++) {
42
+ const manifest = await readFile(join(directory, 'package.json'), 'utf8').then(text => JSON.parse(text)).catch(() => undefined);
43
+ const candidates = [...(manifest ? [join(directory, 'node_modules')] : []), ...(basename(directory) === 'node_modules' ? [directory] : [])];
44
+ for (const candidate of candidates) {
45
+ const physical = await realpath(candidate).catch(() => undefined);
46
+ // Installation links may point to an installed module store, never a
47
+ // source checkout, home or root. Keep the lexical module directory too:
48
+ // Node must inspect package aliases before resolving their real targets.
49
+ if (!physical || basename(physical) !== 'node_modules' || !(await stat(physical)).isDirectory())
50
+ continue;
51
+ roots.add(candidate);
52
+ roots.add(physical);
53
+ }
54
+ if (manifest?.workspaces || basename(directory) === 'node_modules' && !directory.includes(`${join('node_modules', '.pnpm')}`))
55
+ break;
56
+ directory = dirname(directory);
57
+ }
58
+ return [...roots];
59
+ }
@@ -1,4 +1,4 @@
1
- import { isSideEffectful } from '@hunterzhu/pulse-runtime';
1
+ import { extractToolImage, isSideEffectful } from '@hunterzhu/pulse-runtime';
2
2
  function toJson(value, seen = new Set()) {
3
3
  if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number')
4
4
  return value;
@@ -102,7 +102,10 @@ export function createToolEffectExecutor(registry) {
102
102
  value = null;
103
103
  artifact = artifactOutput(detailed.output);
104
104
  }
105
- return { value, ...(detailed.normalized === undefined ? {} : { normalized: toJson(detailed.normalized) }), ...(artifact === undefined ? {} : { artifact }), ...(summary === undefined ? {} : { summary }), sideEffectState: isSideEffectful(definition.manifest.sideEffectPolicy) ? 'applied' : 'none', executionState: 'succeeded', ...(executionRef === undefined ? {} : { executionRef }), metadata: { toolVersion: detailed.manifest.version, retrySafety: detailed.manifest.retrySafety, defaultTimeoutMs: detailed.manifest.defaultTimeoutMs, observationCount: observations.length, ...(artifact === undefined ? {} : { artifactMediaType: artifact.mediaType }) }, ...(observations.length ? { observations } : {}) };
105
+ const image = extractToolImage(value);
106
+ value = image.value;
107
+ artifact = image.artifact ?? artifact;
108
+ return { value, ...(detailed.normalized === undefined ? {} : { normalized: image.artifact ? value : toJson(detailed.normalized) }), ...(artifact === undefined ? {} : { artifact }), ...(summary === undefined ? {} : { summary }), sideEffectState: isSideEffectful(definition.manifest.sideEffectPolicy) ? 'applied' : 'none', executionState: 'succeeded', ...(executionRef === undefined ? {} : { executionRef }), metadata: { toolVersion: detailed.manifest.version, retrySafety: detailed.manifest.retrySafety, defaultTimeoutMs: detailed.manifest.defaultTimeoutMs, observationCount: observations.length, ...(artifact === undefined ? {} : { artifactMediaType: artifact.mediaType }) }, ...(observations.length ? { observations } : {}) };
106
109
  };
107
110
  }
108
111
  export async function reconcileToolEffect(registry, effect, signal) {
@@ -1,2 +1,2 @@
1
1
  import { type SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
2
- export declare function initializeShellSandbox(policy: SandboxRuntimeConfig, platform?: NodeJS.Platform): Promise<void>;
2
+ export declare function initializeShellSandbox(policy: SandboxRuntimeConfig, platform?: NodeJS.Platform, signal?: AbortSignal): Promise<void>;
@@ -1,17 +1,73 @@
1
- import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
2
- export async function initializeShellSandbox(policy, platform = process.platform) {
1
+ import { realpath } from 'node:fs/promises';
2
+ import { win32 } from 'node:path';
3
+ import { setImmediate } from 'node:timers/promises';
4
+ import { SandboxManager, expandWindowsFsPaths, getWindowsSandboxUserStatusAsync, resolveSrtWin, stampWindowsAcl } from '@anthropic-ai/sandbox-runtime';
5
+ const windowsAclBatchSize = 8;
6
+ async function windowsDenyBatches(policy) {
7
+ const fs = policy.filesystem;
8
+ // Credential-derived file denies belong to the SDK's own convergence pass.
9
+ if (!fs || fs.disabled || policy.credentials)
10
+ return undefined;
11
+ const reads = expandWindowsFsPaths(fs.denyRead ?? [], { mode: 'deny' });
12
+ const writes = expandWindowsFsPaths(fs.denyWrite ?? [], { mode: 'deny' });
13
+ if (reads.length + writes.length <= windowsAclBatchSize)
14
+ return undefined;
15
+ // Resolve aliases before merging masks: a later write-only deny must never
16
+ // downgrade a read deny on the same physical object. Let the SDK handle
17
+ // missing paths and its placeholder lifecycle without changing semantics.
18
+ const physical = await Promise.all([...reads, ...writes].map(path => realpath(path).catch(() => undefined)));
19
+ if (physical.some(path => path === undefined))
20
+ return undefined;
21
+ const targets = new Map();
22
+ physical.forEach((path, index) => {
23
+ const key = win32.normalize(path).toLowerCase();
24
+ if (!targets.has(key))
25
+ targets.set(key, { path: path, read: index < reads.length });
26
+ });
27
+ const batches = [];
28
+ const entries = [...targets.values()];
29
+ for (let offset = 0; offset < entries.length; offset += windowsAclBatchSize) {
30
+ const batch = { denyRead: [], denyWrite: [] };
31
+ for (const target of entries.slice(offset, offset + windowsAclBatchSize))
32
+ (target.read ? batch.denyRead : batch.denyWrite).push(target.path);
33
+ batches.push(batch);
34
+ }
35
+ return batches.length > 1 ? batches : undefined;
36
+ }
37
+ export async function initializeShellSandbox(policy, platform = process.platform, signal) {
38
+ signal?.throwIfAborted();
39
+ const batches = platform === 'win32' ? await windowsDenyBatches(policy) : undefined;
40
+ signal?.throwIfAborted();
41
+ const initial = batches ? { ...policy, filesystem: { ...policy.filesystem, ...batches[0] } } : policy;
3
42
  try {
4
- await SandboxManager.initialize(policy, undefined, false);
43
+ await SandboxManager.initialize(initial, undefined, false);
5
44
  }
6
45
  catch (error) {
7
- // A cold Windows sandbox account can time out during the WFP readiness
8
- // probe. Retry that probe once after cleanup, before any command is run.
9
- // A failed isolation check, ACL error, or second timeout still fails closed.
46
+ // Retry only the cold WFP readiness probe, before any command is run.
10
47
  if (platform !== 'win32' || !error || typeof error !== 'object'
11
48
  || !('code' in error) || error.code !== 'srt_win_timeout'
12
49
  || !('subcommand' in error) || error.subcommand !== 'wfp')
13
50
  throw error;
14
51
  await SandboxManager.reset();
15
- await SandboxManager.initialize(policy, undefined, false);
52
+ await SandboxManager.initialize(initial, undefined, false);
53
+ }
54
+ signal?.throwIfAborted();
55
+ if (!batches)
56
+ return;
57
+ const srtWin = resolveSrtWin(policy.windows?.srtWin);
58
+ const status = await getWindowsSandboxUserStatusAsync({ srtWin });
59
+ if (!status.provisioned || !status.sid)
60
+ throw new Error('Windows sandbox not provisioned');
61
+ for (const batch of batches.slice(1)) {
62
+ signal?.throwIfAborted();
63
+ stampWindowsAcl({ ...batch, sandboxUserSid: status.sid, holderPid: process.pid, srtWin });
64
+ await setImmediate();
16
65
  }
66
+ signal?.throwIfAborted();
67
+ // Additional batches use the SAME holder PID. The SDK's reset() releases all
68
+ // ledger holds for that PID, including partial batches after failure. The
69
+ // first batch ensures its cleanup bookkeeping is active even without grants.
70
+ // Publish the full declared policy only once every native deny has landed;
71
+ // the SDK's cached deny list is only a per-exec deduplication optimization.
72
+ SandboxManager.updateConfig(policy);
17
73
  }
@@ -0,0 +1,23 @@
1
+ /** Bounded memory observation plus a separately bounded, private durable byte stream. */
2
+ export declare class ShellOutput {
3
+ private readonly memoryLimit;
4
+ private readonly directory?;
5
+ private readonly diskLimit;
6
+ private readonly keepTail;
7
+ readonly id: `${string}-${string}-${string}-${string}-${string}`;
8
+ private streams;
9
+ constructor(memoryLimit: number, directory?: string | undefined, diskLimit?: number, keepTail?: boolean);
10
+ append(name: 'stdout' | 'stderr', chunk: Buffer): void;
11
+ text(name: 'stdout' | 'stderr'): string;
12
+ finish(): Record<string, {
13
+ id: string;
14
+ bytes: number;
15
+ storedBytes: number;
16
+ sha256: string;
17
+ storedSha256: string;
18
+ omittedStart: number;
19
+ omittedEnd: number;
20
+ complete: boolean;
21
+ error?: string;
22
+ }>;
23
+ }
@@ -0,0 +1,82 @@
1
+ import { closeSync, mkdirSync, openSync, writeSync } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ /** Bounded memory observation plus a separately bounded, private durable byte stream. */
5
+ export class ShellOutput {
6
+ memoryLimit;
7
+ directory;
8
+ diskLimit;
9
+ keepTail;
10
+ id = randomUUID();
11
+ streams = new Map();
12
+ constructor(memoryLimit, directory, diskLimit = 16 * 1024 * 1024, keepTail = true) {
13
+ this.memoryLimit = memoryLimit;
14
+ this.directory = directory;
15
+ this.diskLimit = diskLimit;
16
+ this.keepTail = keepTail;
17
+ }
18
+ append(name, chunk) {
19
+ let s = this.streams.get(name);
20
+ if (!s) {
21
+ s = { head: Buffer.alloc(0), tail: Buffer.alloc(0), bytes: 0, stored: 0, hash: createHash('sha256'), storedHash: createHash('sha256') };
22
+ if (this.directory)
23
+ try {
24
+ mkdirSync(this.directory, { recursive: true, mode: 0o700 });
25
+ s.fd = openSync(join(this.directory, `${this.id}-${name}.log`), 'wx', 0o600);
26
+ }
27
+ catch {
28
+ s.error = 'OUTPUT_ARCHIVE_OPEN_FAILED';
29
+ }
30
+ this.streams.set(name, s);
31
+ }
32
+ s.bytes += chunk.length;
33
+ s.hash.update(chunk);
34
+ const half = this.keepTail ? Math.floor(this.memoryLimit / 2) : this.memoryLimit;
35
+ if (s.head.length < half)
36
+ s.head = Buffer.concat([s.head, chunk.subarray(0, half - s.head.length)]);
37
+ if (this.keepTail)
38
+ s.tail = Buffer.concat([s.tail, chunk]).subarray(-Math.max(1, this.memoryLimit - half));
39
+ if (s.fd !== undefined && !s.error)
40
+ try {
41
+ const kept = chunk.subarray(0, Math.max(0, this.diskLimit - s.stored));
42
+ let offset = 0;
43
+ while (offset < kept.length)
44
+ offset += writeSync(s.fd, kept, offset, kept.length - offset);
45
+ s.storedHash.update(kept);
46
+ s.stored += offset;
47
+ if (kept.length < chunk.length)
48
+ s.error = 'OUTPUT_ARCHIVE_LIMIT';
49
+ }
50
+ catch {
51
+ s.error = 'OUTPUT_ARCHIVE_WRITE_FAILED';
52
+ }
53
+ }
54
+ text(name) {
55
+ const s = this.streams.get(name);
56
+ if (!s || this.memoryLimit === 0)
57
+ return '';
58
+ // When the output fits, tail contains the suffix after the head without a gap.
59
+ const tail = s.bytes <= this.memoryLimit ? s.tail.subarray(Math.max(0, s.tail.length - (s.bytes - s.head.length))) : s.tail;
60
+ let bytes = Buffer.concat([s.head, tail]).subarray(0, this.memoryLimit);
61
+ while (bytes.length && bytes.toString('utf8').endsWith('\ufffd'))
62
+ bytes = bytes.subarray(0, -1);
63
+ return bytes.toString('utf8');
64
+ }
65
+ finish() {
66
+ const output = {};
67
+ for (const [name, s] of this.streams) {
68
+ if (s.fd !== undefined) {
69
+ try {
70
+ closeSync(s.fd);
71
+ }
72
+ catch {
73
+ s.error = 'OUTPUT_ARCHIVE_CLOSE_FAILED';
74
+ }
75
+ ;
76
+ delete s.fd;
77
+ }
78
+ output[name] = { id: `${this.id}-${name}.log`, bytes: s.bytes, storedBytes: s.stored, sha256: s.hash.digest('hex'), storedSha256: s.storedHash.digest('hex'), omittedStart: s.head.length, omittedEnd: Math.max(s.head.length, s.bytes - s.tail.length), complete: !!this.directory && s.stored === s.bytes && !s.error, ...(s.error ? { error: s.error } : {}) };
79
+ }
80
+ return output;
81
+ }
82
+ }
@@ -1,3 +1,4 @@
1
+ import { ShellOutput } from './shell-output.js';
1
2
  export interface ShellResult {
2
3
  code: number | null;
3
4
  stdout: string;
@@ -5,6 +6,7 @@ export interface ShellResult {
5
6
  truncated: boolean;
6
7
  timedOut: boolean;
7
8
  aborted: boolean;
9
+ outputArchive?: ReturnType<ShellOutput['finish']>;
8
10
  }
9
11
  /** Read-only prerequisite probe; never provisions accounts or changes permissions. */
10
12
  export declare function checkShellSandbox(): Promise<{
@@ -15,6 +17,8 @@ export declare function quoteWindowsArgument(value: string): string;
15
17
  /** Build the command text expected by srt without interpreting model argv as shell syntax. */
16
18
  export declare function encodeSandboxCommand(command: string, args: string[], platform?: NodeJS.Platform): string;
17
19
  export declare function decodeUtf8WithinByteLimit(value: Buffer, maxBytes: number): string;
20
+ /** Deterministic argv validation, shared by pre-approval and actual execution. */
21
+ export declare function validateShellInvocation(command: string, args?: string[]): void;
18
22
  export declare function runShell(command: string, args?: string[], options?: {
19
23
  cwd?: string;
20
24
  signal?: AbortSignal;
@@ -22,4 +26,6 @@ export declare function runShell(command: string, args?: string[], options?: {
22
26
  maxOutputBytes?: number;
23
27
  env?: NodeJS.ProcessEnv;
24
28
  allowedDomains?: string[];
29
+ outputDirectory?: string;
30
+ maxArchiveBytes?: number;
25
31
  }): Promise<ShellResult>;