@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.
- package/dist/documents/docx.d.ts +2 -0
- package/dist/documents/docx.js +45 -0
- package/dist/documents/tools.d.ts +12 -0
- package/dist/documents/tools.js +23 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/stdio.js +12 -1
- package/dist/native/LICENSE +201 -0
- package/dist/native/srt-win.exe +0 -0
- package/dist/native/srt-win.json +11 -0
- package/dist/providers/anthropic.d.ts +1 -0
- package/dist/providers/anthropic.js +7 -50
- package/dist/providers/canonical/lowering.js +12 -0
- package/dist/providers/canonical/mappers/anthropic.js +4 -3
- package/dist/providers/canonical/mappers/openai.js +1 -1
- package/dist/providers/normalize.d.ts +6 -1
- package/dist/providers/normalize.js +29 -3
- package/dist/providers/openai-compat.d.ts +2 -0
- package/dist/providers/openai-compat.js +10 -1
- package/dist/providers/runtime-executor.d.ts +8 -1
- package/dist/providers/runtime-executor.js +55 -10
- package/dist/providers/types.d.ts +2 -0
- package/dist/tools/installation-dependencies.d.ts +4 -0
- package/dist/tools/installation-dependencies.js +59 -0
- package/dist/tools/registry.js +5 -2
- package/dist/tools/sandbox-initialization.d.ts +1 -1
- package/dist/tools/sandbox-initialization.js +63 -7
- package/dist/tools/shell-output.d.ts +23 -0
- package/dist/tools/shell-output.js +82 -0
- package/dist/tools/shell.d.ts +6 -0
- package/dist/tools/shell.js +62 -24
- package/dist/tools/windows-read-policy.d.ts +4 -0
- package/dist/tools/windows-read-policy.js +11 -1
- package/dist/tools/windows-srt-path.d.ts +2 -0
- package/dist/tools/windows-srt-path.js +18 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
213
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
2
|
-
|
|
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(
|
|
43
|
+
await SandboxManager.initialize(initial, undefined, false);
|
|
5
44
|
}
|
|
6
45
|
catch (error) {
|
|
7
|
-
//
|
|
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(
|
|
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
|
+
}
|
package/dist/tools/shell.d.ts
CHANGED
|
@@ -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>;
|