@namzu/sdk 60.0.0 → 60.2.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/CHANGELOG.md +40 -0
- package/dist/agents/QueryAgent.d.ts.map +1 -1
- package/dist/agents/QueryAgent.js +1 -0
- package/dist/agents/QueryAgent.js.map +1 -1
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +19 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/acp/update.d.ts.map +1 -1
- package/dist/bridge/acp/update.js +1 -0
- package/dist/bridge/acp/update.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +28 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/constants/continuation.d.ts +9 -0
- package/dist/constants/continuation.d.ts.map +1 -1
- package/dist/constants/continuation.js +9 -0
- package/dist/constants/continuation.js.map +1 -1
- package/dist/contracts/session/api.d.ts +8 -2
- package/dist/contracts/session/api.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts +7 -1
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +20 -0
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/peers/dir.d.ts.map +1 -1
- package/dist/peers/dir.js +3 -0
- package/dist/peers/dir.js.map +1 -1
- package/dist/provider/collect-chat-completion.d.ts.map +1 -1
- package/dist/provider/collect-chat-completion.js +4 -0
- package/dist/provider/collect-chat-completion.js.map +1 -1
- package/dist/provider/fallback.d.ts.map +1 -1
- package/dist/provider/fallback.js +11 -0
- package/dist/provider/fallback.js.map +1 -1
- package/dist/provider/idle-timeout.d.ts.map +1 -1
- package/dist/provider/idle-timeout.js +5 -0
- package/dist/provider/idle-timeout.js.map +1 -1
- package/dist/provider/mock.d.ts +2 -0
- package/dist/provider/mock.d.ts.map +1 -1
- package/dist/provider/mock.js +16 -8
- package/dist/provider/mock.js.map +1 -1
- package/dist/provider/model-window-suffix.d.ts.map +1 -1
- package/dist/provider/model-window-suffix.js +1 -0
- package/dist/provider/model-window-suffix.js.map +1 -1
- package/dist/provider/retry.d.ts.map +1 -1
- package/dist/provider/retry.js +5 -0
- package/dist/provider/retry.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/events.d.ts.map +1 -1
- package/dist/runtime/query/events.js +17 -0
- package/dist/runtime/query/events.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +23 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +88 -9
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +14 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +14 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
- package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
- package/dist/runtime/query/iteration/effort-clamp.js +42 -0
- package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
- package/dist/runtime/query/iteration/index.d.ts +34 -0
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +199 -6
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +6 -0
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +6 -0
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/runtime/query/review-policy.d.ts.map +1 -1
- package/dist/runtime/query/review-policy.js +12 -2
- package/dist/runtime/query/review-policy.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +2 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +1 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +6 -5
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/session/paths.d.ts +15 -22
- package/dist/session/paths.d.ts.map +1 -1
- package/dist/session/paths.js +19 -46
- package/dist/session/paths.js.map +1 -1
- package/dist/store/memory/disk.d.ts.map +1 -1
- package/dist/store/memory/disk.js +1 -1
- package/dist/store/memory/disk.js.map +1 -1
- package/dist/store/memory/markdown.d.ts.map +1 -1
- package/dist/store/memory/markdown.js +1 -1
- package/dist/store/memory/markdown.js.map +1 -1
- package/dist/store/memory/operation-lock.d.ts +20 -4
- package/dist/store/memory/operation-lock.d.ts.map +1 -1
- package/dist/store/memory/operation-lock.js +235 -58
- package/dist/store/memory/operation-lock.js.map +1 -1
- package/dist/store/session-log/core.d.ts +43 -0
- package/dist/store/session-log/core.d.ts.map +1 -1
- package/dist/store/session-log/core.js +190 -3
- package/dist/store/session-log/core.js.map +1 -1
- package/dist/store/session-log/disk.d.ts +1 -1
- package/dist/store/session-log/disk.d.ts.map +1 -1
- package/dist/store/session-log/disk.js +1 -0
- package/dist/store/session-log/disk.js.map +1 -1
- package/dist/store/session-log/index.d.ts +1 -1
- package/dist/store/session-log/index.d.ts.map +1 -1
- package/dist/store/session-log/index.js +1 -1
- package/dist/store/session-log/index.js.map +1 -1
- package/dist/tools/builtins/bash-cwd.d.ts.map +1 -1
- package/dist/tools/builtins/bash-cwd.js +2 -3
- package/dist/tools/builtins/bash-cwd.js.map +1 -1
- package/dist/tools/builtins/read-media.js +3 -3
- package/dist/tools/builtins/read-media.js.map +1 -1
- package/dist/turn/reporter.d.ts.map +1 -1
- package/dist/turn/reporter.js +13 -0
- package/dist/turn/reporter.js.map +1 -1
- package/dist/types/agent/query.d.ts +2 -0
- package/dist/types/agent/query.d.ts.map +1 -1
- package/dist/types/hitl/index.d.ts +8 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/message/index.d.ts +1 -1
- package/dist/types/message/index.d.ts.map +1 -1
- package/dist/types/message/index.js +1 -0
- package/dist/types/message/index.js.map +1 -1
- package/dist/types/provider/chat.d.ts +19 -0
- package/dist/types/provider/chat.d.ts.map +1 -1
- package/dist/types/provider/config.d.ts +7 -0
- package/dist/types/provider/config.d.ts.map +1 -1
- package/dist/types/provider/index.d.ts +1 -1
- package/dist/types/provider/index.d.ts.map +1 -1
- package/dist/types/provider/interface.d.ts +15 -0
- package/dist/types/provider/interface.d.ts.map +1 -1
- package/dist/types/provider/stream.d.ts +6 -0
- package/dist/types/provider/stream.d.ts.map +1 -1
- package/dist/types/session/events.d.ts +72 -2
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js +4 -0
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts +34 -34
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +5 -0
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/session/stop-reason.d.ts +11 -0
- package/dist/types/session/stop-reason.d.ts.map +1 -1
- package/dist/utils/temp.d.ts +82 -0
- package/dist/utils/temp.d.ts.map +1 -0
- package/dist/utils/temp.js +196 -0
- package/dist/utils/temp.js.map +1 -0
- package/package.json +1 -1
- package/src/agents/QueryAgent.ts +1 -0
- package/src/bridge/a2a/mapper.ts +22 -0
- package/src/bridge/acp/update.ts +6 -1
- package/src/bridge/sse/mapper.ts +29 -0
- package/src/constants/continuation.ts +13 -0
- package/src/contracts/session/api.ts +8 -0
- package/src/manager/session/turn-recorder.ts +22 -0
- package/src/peers/dir.ts +3 -0
- package/src/provider/collect-chat-completion.ts +3 -0
- package/src/provider/fallback.ts +17 -0
- package/src/provider/idle-timeout.ts +9 -0
- package/src/provider/mock.ts +19 -9
- package/src/provider/model-window-suffix.ts +1 -0
- package/src/provider/retry.ts +9 -0
- package/src/public-runtime.ts +13 -0
- package/src/runtime/query/events.ts +19 -0
- package/src/runtime/query/executor.ts +92 -8
- package/src/runtime/query/index.ts +27 -0
- package/src/runtime/query/iteration/effort-clamp.ts +46 -0
- package/src/runtime/query/iteration/index.ts +260 -6
- package/src/runtime/query/iteration/phases/context.ts +2 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +6 -0
- package/src/runtime/query/iteration/stream-turn.ts +5 -0
- package/src/runtime/query/review-policy.ts +13 -2
- package/src/runtime/query/tooling.ts +3 -0
- package/src/sandbox/provider/local.ts +8 -5
- package/src/session/paths.ts +32 -57
- package/src/store/memory/disk.ts +1 -0
- package/src/store/memory/markdown.ts +1 -0
- package/src/store/memory/operation-lock.ts +288 -57
- package/src/store/session-log/core.ts +235 -3
- package/src/store/session-log/disk.ts +7 -1
- package/src/store/session-log/index.ts +4 -0
- package/src/tools/builtins/bash-cwd.ts +2 -3
- package/src/tools/builtins/read-media.ts +3 -3
- package/src/turn/reporter.ts +14 -0
- package/src/types/agent/query.ts +2 -0
- package/src/types/hitl/index.ts +8 -0
- package/src/types/message/index.ts +5 -3
- package/src/types/provider/chat.ts +20 -0
- package/src/types/provider/config.ts +7 -0
- package/src/types/provider/index.ts +1 -0
- package/src/types/provider/interface.ts +20 -0
- package/src/types/provider/stream.ts +6 -0
- package/src/types/session/events.ts +106 -6
- package/src/types/session/records.ts +5 -0
- package/src/types/session/stop-reason.ts +11 -0
- package/src/utils/temp.ts +250 -0
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type SessionLog,
|
|
13
13
|
type SessionLogEntry,
|
|
14
14
|
type SessionLogHead,
|
|
15
|
+
type SessionLogWriteHealth,
|
|
15
16
|
SessionMessageFold,
|
|
16
17
|
withMessageId,
|
|
17
18
|
} from '../../store/session-log/index.js'
|
|
@@ -130,6 +131,7 @@ const TURN_BOUND_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set<SessionEve
|
|
|
130
131
|
'hosted_tool',
|
|
131
132
|
'provider_retry',
|
|
132
133
|
'provider_fallback',
|
|
134
|
+
'model_refusal',
|
|
133
135
|
'tool_completed',
|
|
134
136
|
'user_question_asked',
|
|
135
137
|
'user_question_answered',
|
|
@@ -650,6 +652,9 @@ export class TurnRecorder {
|
|
|
650
652
|
this.#leaseHeartbeat = setInterval(() => this.#queueLeaseRenewal(), this.#leaseTtlMs / 2)
|
|
651
653
|
this.#leaseHeartbeat.unref()
|
|
652
654
|
}
|
|
655
|
+
this.#stopWatchingHealth ??= this.log.onWriteHealth?.((change) => {
|
|
656
|
+
for (const listener of this.#healthListeners) listener(change)
|
|
657
|
+
})
|
|
653
658
|
const head = await this.log.head()
|
|
654
659
|
if (head === null) {
|
|
655
660
|
this.#lastEntry = await this.log.append(lease, {
|
|
@@ -680,6 +685,21 @@ export class TurnRecorder {
|
|
|
680
685
|
}
|
|
681
686
|
}
|
|
682
687
|
|
|
688
|
+
/**
|
|
689
|
+
* Be told when the session log stops writing (the disk is full) and when
|
|
690
|
+
* it writes again. Sent while the recorder holds the log, from `open` to
|
|
691
|
+
* `release`.
|
|
692
|
+
*/
|
|
693
|
+
onLogWriteHealth(listener: (change: SessionLogWriteHealth) => void): () => void {
|
|
694
|
+
this.#healthListeners.add(listener)
|
|
695
|
+
return () => {
|
|
696
|
+
this.#healthListeners.delete(listener)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
readonly #healthListeners = new Set<(change: SessionLogWriteHealth) => void>()
|
|
701
|
+
#stopWatchingHealth: (() => void) | undefined
|
|
702
|
+
|
|
683
703
|
/** Serialize timer and write-triggered renewals so release uses the latest token. */
|
|
684
704
|
#queueLeaseRenewal(): void {
|
|
685
705
|
this.#leaseRenewal = this.#leaseRenewal
|
|
@@ -724,6 +744,8 @@ export class TurnRecorder {
|
|
|
724
744
|
|
|
725
745
|
/** Give up a lease the recorder claimed. Never throws. */
|
|
726
746
|
async release(): Promise<void> {
|
|
747
|
+
this.#stopWatchingHealth?.()
|
|
748
|
+
this.#stopWatchingHealth = undefined
|
|
727
749
|
if (!this.#ownsLease || !this.#lease) return
|
|
728
750
|
this.#released = true
|
|
729
751
|
if (this.#leaseHeartbeat !== undefined) clearInterval(this.#leaseHeartbeat)
|
package/src/peers/dir.ts
CHANGED
|
@@ -123,6 +123,9 @@ function peerRuntimeDirCandidates(options: ResolvePeerRuntimeDirOptions): readon
|
|
|
123
123
|
const candidates: string[] = []
|
|
124
124
|
if (options.env.XDG_RUNTIME_DIR) candidates.push(join(options.env.XDG_RUNTIME_DIR, 'namzu'))
|
|
125
125
|
candidates.push(join(options.namzuHome, 'run'))
|
|
126
|
+
// The operating system's own temporary directory, not `NAMZU_TMPDIR`: this
|
|
127
|
+
// is the last resort for a socket path, and a relocated base is usually a
|
|
128
|
+
// long path that would not fit the platform's socket-path limit.
|
|
126
129
|
candidates.push(join(options.tmpDir ?? tmpdir(), `namzu-${options.uid ?? 'user'}`))
|
|
127
130
|
return candidates
|
|
128
131
|
}
|
|
@@ -72,6 +72,7 @@ export async function collectChatCompletion(
|
|
|
72
72
|
let replayState: unknown
|
|
73
73
|
let finishReason: ChatCompletionResponse['finishReason'] = 'stop'
|
|
74
74
|
let finishDetail: ChatCompletionResponse['finishDetail']
|
|
75
|
+
let refusal: ChatCompletionResponse['refusal']
|
|
75
76
|
let usage: ChatCompletionResponse['usage'] = {
|
|
76
77
|
promptTokens: 0,
|
|
77
78
|
completionTokens: 0,
|
|
@@ -158,6 +159,7 @@ export async function collectChatCompletion(
|
|
|
158
159
|
finishReason = chunk.finishReason
|
|
159
160
|
finishDetail = chunk.finishReason === 'length' ? chunk.finishDetail : undefined
|
|
160
161
|
}
|
|
162
|
+
if (chunk.refusal) refusal = chunk.refusal
|
|
161
163
|
// Merge (per-field max), not last-write-wins: a late frame that omits
|
|
162
164
|
// input/cache tokens must not zero the counts captured earlier in the stream.
|
|
163
165
|
if (chunk.usage) usage = mergeTokenUsage(usage, chunk.usage)
|
|
@@ -207,6 +209,7 @@ export async function collectChatCompletion(
|
|
|
207
209
|
},
|
|
208
210
|
finishReason,
|
|
209
211
|
...(finishDetail ? { finishDetail } : {}),
|
|
212
|
+
...(refusal && finishReason === 'content_filter' ? { refusal } : {}),
|
|
210
213
|
usage,
|
|
211
214
|
}
|
|
212
215
|
}
|
package/src/provider/fallback.ts
CHANGED
|
@@ -572,6 +572,23 @@ export function withProviderFallback(
|
|
|
572
572
|
) => first.provider.effortLevelsFor?.(model, thinking) ?? [],
|
|
573
573
|
}
|
|
574
574
|
: {}),
|
|
575
|
+
// Answered by the member the next request goes to first, the one the
|
|
576
|
+
// cursor rests on: it is the member whose model just declined.
|
|
577
|
+
...(members.some((member) => member.provider.refusalFallbackFor)
|
|
578
|
+
? {
|
|
579
|
+
refusalFallbackFor: (
|
|
580
|
+
model: string,
|
|
581
|
+
refusal: Parameters<NonNullable<LLMProvider['refusalFallbackFor']>>[1],
|
|
582
|
+
signal?: AbortSignal,
|
|
583
|
+
) => {
|
|
584
|
+
const serving = members[cursor] ?? first
|
|
585
|
+
return (
|
|
586
|
+
serving.provider.refusalFallbackFor?.(serving.model ?? model, refusal, signal) ??
|
|
587
|
+
Promise.resolve(undefined)
|
|
588
|
+
)
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
: {}),
|
|
575
592
|
...(first.provider.resolveContextWindow
|
|
576
593
|
? {
|
|
577
594
|
resolveContextWindow: (model: string, signal?: AbortSignal) =>
|
|
@@ -247,6 +247,15 @@ export function withStreamIdleTimeout(
|
|
|
247
247
|
) => provider.effortLevelsFor?.(model, thinking) ?? [],
|
|
248
248
|
}
|
|
249
249
|
: {}),
|
|
250
|
+
...(provider.refusalFallbackFor
|
|
251
|
+
? {
|
|
252
|
+
refusalFallbackFor: (
|
|
253
|
+
model: string,
|
|
254
|
+
refusal: Parameters<NonNullable<LLMProvider['refusalFallbackFor']>>[1],
|
|
255
|
+
signal?: AbortSignal,
|
|
256
|
+
) => provider.refusalFallbackFor?.(model, refusal, signal) ?? Promise.resolve(undefined),
|
|
257
|
+
}
|
|
258
|
+
: {}),
|
|
250
259
|
...(provider.resolveContextWindow
|
|
251
260
|
? {
|
|
252
261
|
resolveContextWindow: (model: string, signal?: AbortSignal) =>
|
package/src/provider/mock.ts
CHANGED
|
@@ -49,11 +49,19 @@ export class MockLLMProvider implements LLMProvider {
|
|
|
49
49
|
/** Every request this provider has received, in order. */
|
|
50
50
|
readonly requests: ChatCompletionParams[] = []
|
|
51
51
|
|
|
52
|
+
/** Present only when the script names a model to retry a refusal on. */
|
|
53
|
+
readonly refusalFallbackFor?: (model: string) => Promise<string | undefined>
|
|
54
|
+
|
|
52
55
|
constructor(config: MockScript = {}) {
|
|
53
56
|
this.capabilities = config.capabilities
|
|
54
57
|
this.model = config.model ?? FALLBACK_MOCK_MODEL
|
|
55
58
|
this.responseDelayMs = config.responseDelayMs ?? 0
|
|
56
59
|
this.onRequest = config.onRequest
|
|
60
|
+
const refusalFallbackModel = config.refusalFallbackModel
|
|
61
|
+
if (refusalFallbackModel !== undefined) {
|
|
62
|
+
this.refusalFallbackFor = async (model) =>
|
|
63
|
+
model === refusalFallbackModel ? undefined : refusalFallbackModel
|
|
64
|
+
}
|
|
57
65
|
this.nextTurn = config.nextTurn
|
|
58
66
|
this.turns = config.turns ?? [{ text: config.responseText ?? 'Mock provider response' }] // Back-compat: the old single-string config becomes a one-turn script.
|
|
59
67
|
}
|
|
@@ -158,18 +166,20 @@ export class MockLLMProvider implements LLMProvider {
|
|
|
158
166
|
yield { id, delta: { toolCallEnd: { index, id: callId } } }
|
|
159
167
|
}
|
|
160
168
|
|
|
169
|
+
const finishReason: NonNullable<StreamChunk['finishReason']> =
|
|
170
|
+
turn.finishReason ??
|
|
171
|
+
// A call cut off mid-JSON is what the output limit does, and the
|
|
172
|
+
// finish reason is how a consumer tells that from malformed JSON.
|
|
173
|
+
(toolCalls.some((call) => call.truncateArguments === true)
|
|
174
|
+
? 'length'
|
|
175
|
+
: toolCalls.length > 0
|
|
176
|
+
? 'tool_calls'
|
|
177
|
+
: 'stop')
|
|
161
178
|
yield {
|
|
162
179
|
id,
|
|
163
180
|
delta: {},
|
|
164
|
-
finishReason
|
|
165
|
-
|
|
166
|
-
// A call cut off mid-JSON is what the output limit does, and the
|
|
167
|
-
// finish reason is how a consumer tells that from malformed JSON.
|
|
168
|
-
(toolCalls.some((call) => call.truncateArguments === true)
|
|
169
|
-
? 'length'
|
|
170
|
-
: toolCalls.length > 0
|
|
171
|
-
? 'tool_calls'
|
|
172
|
-
: 'stop'),
|
|
181
|
+
finishReason,
|
|
182
|
+
...(finishReason === 'content_filter' ? { refusal: turn.refusal ?? {} } : {}),
|
|
173
183
|
usage: { ...EMPTY_USAGE, ...turn.usage },
|
|
174
184
|
}
|
|
175
185
|
}
|
package/src/provider/retry.ts
CHANGED
|
@@ -417,6 +417,15 @@ export function withProviderRetry(
|
|
|
417
417
|
) => provider.effortLevelsFor?.(model, thinking) ?? [],
|
|
418
418
|
}
|
|
419
419
|
: {}),
|
|
420
|
+
...(provider.refusalFallbackFor
|
|
421
|
+
? {
|
|
422
|
+
refusalFallbackFor: (
|
|
423
|
+
model: string,
|
|
424
|
+
refusal: Parameters<NonNullable<LLMProvider['refusalFallbackFor']>>[1],
|
|
425
|
+
signal?: AbortSignal,
|
|
426
|
+
) => provider.refusalFallbackFor?.(model, refusal, signal) ?? Promise.resolve(undefined),
|
|
427
|
+
}
|
|
428
|
+
: {}),
|
|
420
429
|
...(provider.resolveContextWindow
|
|
421
430
|
? {
|
|
422
431
|
resolveContextWindow: (model: string, signal?: AbortSignal) =>
|
package/src/public-runtime.ts
CHANGED
|
@@ -291,6 +291,19 @@ export {
|
|
|
291
291
|
} from './utils/shell-compress.js'
|
|
292
292
|
export { createChildAbortController } from './utils/abort.js'
|
|
293
293
|
export { memoizeAsync } from './utils/memoize.js'
|
|
294
|
+
export {
|
|
295
|
+
NAMZU_TMPDIR_ENV,
|
|
296
|
+
type PrivateTempRootOptions,
|
|
297
|
+
TempDirectoryError,
|
|
298
|
+
type TempDirectoryFailure,
|
|
299
|
+
createTempDir,
|
|
300
|
+
createTempDirSync,
|
|
301
|
+
ensureScratchpadDirectory,
|
|
302
|
+
privateTempRoot,
|
|
303
|
+
privateTempRootPath,
|
|
304
|
+
resolveTempBase,
|
|
305
|
+
tempFilePath,
|
|
306
|
+
} from './utils/temp.js'
|
|
294
307
|
export { extractFinalResponse } from './utils/conversation.js'
|
|
295
308
|
|
|
296
309
|
// ─── router, runtime, turn ───────────────────────────────────────────────
|
|
@@ -79,6 +79,25 @@ export class EventTranslator {
|
|
|
79
79
|
this.recorder = recorder
|
|
80
80
|
this.probes = probeRegistry
|
|
81
81
|
this.log = resolveLogger(log).child({ [SCOPE_ATTRIBUTE]: 'runtime/query/events' })
|
|
82
|
+
recorder.onLogWriteHealth((change) => {
|
|
83
|
+
// Live only: the log is what cannot be written. The listener runs inside
|
|
84
|
+
// a log write, so it must not wait on one.
|
|
85
|
+
void this.emitEvent(
|
|
86
|
+
change.state === 'degraded'
|
|
87
|
+
? {
|
|
88
|
+
type: 'session_log_degraded',
|
|
89
|
+
cause: change.cause,
|
|
90
|
+
bufferedRecords: change.bufferedRecords,
|
|
91
|
+
bufferedBytes: change.bufferedBytes,
|
|
92
|
+
...(change.dropping ? { dropping: true } : {}),
|
|
93
|
+
}
|
|
94
|
+
: {
|
|
95
|
+
type: 'session_log_recovered',
|
|
96
|
+
flushedRecords: change.flushedRecords,
|
|
97
|
+
droppedRecords: change.droppedRecords,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
})
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
/** Serializes appends and reads against each other. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { join, resolve } from 'node:path'
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
3
3
|
import type { Span } from '@opentelemetry/api'
|
|
4
4
|
import type { AuthorizationGate } from '../../authorization/gate.js'
|
|
5
5
|
import { unknownProgramInLine } from '../../authorization/program.js'
|
|
@@ -24,7 +24,7 @@ import { fingerprintContent } from '../../tools/builtins/content-fingerprint.js'
|
|
|
24
24
|
import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell.js'
|
|
25
25
|
import { isAlwaysDestructive } from '../../tools/defineTool.js'
|
|
26
26
|
import { createFileReadTracker } from '../../tools/file-read-tracker.js'
|
|
27
|
-
import { pathOutsideRoots, toolRoots } from '../../tools/paths.js'
|
|
27
|
+
import { pathOutsideRoots, resolveWithinAnyReal, toolRoots } from '../../tools/paths.js'
|
|
28
28
|
import { resolveRetainedOutput } from '../../tools/retained-output.js'
|
|
29
29
|
import type { ToolManager } from '../../toolsets/manager.js'
|
|
30
30
|
import type { ToolSourceRef } from '../../toolsets/types.js'
|
|
@@ -70,6 +70,7 @@ import { generateToolCallId } from '../../utils/id.js'
|
|
|
70
70
|
import type { Logger } from '../../utils/logger.js'
|
|
71
71
|
import { compressShellOutput } from '../../utils/shell-compress.js'
|
|
72
72
|
import { parseStructuredResultJson } from '../../utils/structured-result-json.js'
|
|
73
|
+
import { ensureScratchpadDirectory } from '../../utils/temp.js'
|
|
73
74
|
import { type BackgroundJobRegistry, type JobProcess, bindOwner } from '../jobs/registry.js'
|
|
74
75
|
import { type BatchDetach, stillRunningResult } from './detached-calls.js'
|
|
75
76
|
import {
|
|
@@ -155,12 +156,15 @@ export interface PreparedToolBatch {
|
|
|
155
156
|
readonly escalation?: ToolCallEscalation
|
|
156
157
|
/** What a `pre_tool_use` hook asked for this call's approval; see `ToolCallSummary.hookPermission`. */
|
|
157
158
|
readonly hookPermission?: PluginHookPermission
|
|
159
|
+
/** The call writes inside the session's scratch folder; see `ToolCallSummary.scratchpadWrite`. */
|
|
160
|
+
readonly scratchpadWrite?: true
|
|
158
161
|
}[]
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
interface OwnedPreparedToolBatch extends PreparedToolBatch {
|
|
162
165
|
readonly calls: ReadonlyMap<string, PreparedDirectCall>
|
|
163
166
|
readonly escalations: ReadonlyMap<string, ToolCallEscalation>
|
|
167
|
+
readonly scratchpadWrites: ReadonlySet<string>
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
/**
|
|
@@ -363,6 +367,13 @@ export interface ToolExecutorConfig {
|
|
|
363
367
|
workingDirectory: string
|
|
364
368
|
/** See `ToolContext.additionalDirectories`. */
|
|
365
369
|
additionalDirectories?: readonly string[]
|
|
370
|
+
/**
|
|
371
|
+
* Where this session's own scratch folder is (it need not exist). It becomes
|
|
372
|
+
* a root of the file tools, and a write inside it needs no review, once a call
|
|
373
|
+
* names it and the folder has been made and checked. Unset on a sandboxed
|
|
374
|
+
* turn and on a session with no layout on disk.
|
|
375
|
+
*/
|
|
376
|
+
scratchpadDirectory?: string
|
|
366
377
|
/** See `QueryParams.outsideRootAccess`. Default `'refuse'`. */
|
|
367
378
|
outsideRootAccess?: 'refuse' | 'review'
|
|
368
379
|
/** See `QueryParams.sandboxEscape`. Default `'refuse'`. */
|
|
@@ -814,6 +825,68 @@ export class ToolExecutor {
|
|
|
814
825
|
}
|
|
815
826
|
|
|
816
827
|
/** True once the turn has a sandbox, or is certain to get one before a tool runs. */
|
|
828
|
+
/** The scratch folder, once a call has named it and it has been made and checked. */
|
|
829
|
+
private scratchpadReady: string | undefined
|
|
830
|
+
|
|
831
|
+
/** The tools' roots now: the turn's, plus the scratch folder once it is ready. */
|
|
832
|
+
private rootsNow(): readonly string[] {
|
|
833
|
+
return toolRoots({
|
|
834
|
+
workingDirectory: this.config.workingDirectory,
|
|
835
|
+
additionalDirectories: this.additionalDirectoriesNow(),
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private additionalDirectoriesNow(): readonly string[] | undefined {
|
|
840
|
+
if (this.scratchpadReady === undefined) return this.config.additionalDirectories
|
|
841
|
+
return [...(this.config.additionalDirectories ?? []), this.scratchpadReady]
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Make the session's scratch folder a root when a call names it. Lazy: a
|
|
846
|
+
* session that never uses it leaves nothing on disk. The folder must be
|
|
847
|
+
* inside the private temporary root with no link on the way; otherwise it
|
|
848
|
+
* stays outside the roots and a path in it is asked about like any other.
|
|
849
|
+
*/
|
|
850
|
+
private admitScratchpad(call: PreparedSafetyCall): void {
|
|
851
|
+
const directory = this.config.scratchpadDirectory
|
|
852
|
+
if (directory === undefined || this.scratchpadReady !== undefined || this.sandboxed) return
|
|
853
|
+
if (call.kind === 'synthetic') return
|
|
854
|
+
let named = false
|
|
855
|
+
try {
|
|
856
|
+
named = JSON.stringify(call.input)?.includes(JSON.stringify(directory).slice(1, -1)) === true
|
|
857
|
+
} catch {
|
|
858
|
+
// An input that cannot be serialised names nothing.
|
|
859
|
+
}
|
|
860
|
+
if (!named) return
|
|
861
|
+
try {
|
|
862
|
+
this.scratchpadReady = ensureScratchpadDirectory(directory)
|
|
863
|
+
} catch (error) {
|
|
864
|
+
this.log.warn('The session scratch folder could not be made, so it stays outside the roots', {
|
|
865
|
+
'exception.message': error instanceof Error ? error.message : String(error),
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Whether `call` writes a file inside the ready scratch folder and nothing else about it is notable. */
|
|
871
|
+
private async writesScratchpad(
|
|
872
|
+
call: PreparedSafetyCall,
|
|
873
|
+
escalation: ToolCallEscalation | undefined,
|
|
874
|
+
): Promise<boolean> {
|
|
875
|
+
const root = this.scratchpadReady
|
|
876
|
+
if (root === undefined || escalation !== undefined || call.kind === 'synthetic') return false
|
|
877
|
+
if (call.toolName !== 'write' && call.toolName !== 'edit') return false
|
|
878
|
+
const tool = this.config.tools.get(call.toolName)
|
|
879
|
+
if (!tool?.pathArgument || call.input === null || typeof call.input !== 'object') return false
|
|
880
|
+
const value = (call.input as Record<string, unknown>)[tool.pathArgument]
|
|
881
|
+
if (typeof value !== 'string' || !isAbsolute(value)) return false
|
|
882
|
+
try {
|
|
883
|
+
await resolveWithinAnyReal([root], value)
|
|
884
|
+
return true
|
|
885
|
+
} catch {
|
|
886
|
+
return false
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
817
890
|
private get sandboxed(): boolean {
|
|
818
891
|
return this.config.sandbox !== undefined || this.sandboxGate !== undefined
|
|
819
892
|
}
|
|
@@ -1126,6 +1199,7 @@ export class ToolExecutor {
|
|
|
1126
1199
|
if ((response.message.toolCalls ?? []).length > 0) await this.awaitSandbox()
|
|
1127
1200
|
const calls = new Map<string, PreparedDirectCall>()
|
|
1128
1201
|
const escalations = new Map<string, ToolCallEscalation>()
|
|
1202
|
+
const scratchpadWrites = new Set<string>()
|
|
1129
1203
|
const earlierPaths: string[] = []
|
|
1130
1204
|
for (const toolCall of response.message.toolCalls ?? []) {
|
|
1131
1205
|
const prepared = await prepareDirectCall(
|
|
@@ -1137,11 +1211,13 @@ export class ToolExecutor {
|
|
|
1137
1211
|
const path = inputPathOf(prepared)
|
|
1138
1212
|
if (path !== undefined) earlierPaths.push(path)
|
|
1139
1213
|
calls.set(toolCall.id, call)
|
|
1214
|
+
this.admitScratchpad(call)
|
|
1140
1215
|
const escalation = await this.escalationOrUnfinished(call)
|
|
1141
1216
|
if (escalation) escalations.set(toolCall.id, escalation)
|
|
1217
|
+
if (await this.writesScratchpad(call, escalation)) scratchpadWrites.add(toolCall.id)
|
|
1142
1218
|
}
|
|
1143
1219
|
this.config.abortSignal.throwIfAborted()
|
|
1144
|
-
return this.publishPreparedBatch(calls, escalations)
|
|
1220
|
+
return this.publishPreparedBatch(calls, escalations, scratchpadWrites)
|
|
1145
1221
|
}
|
|
1146
1222
|
|
|
1147
1223
|
/**
|
|
@@ -1250,7 +1326,7 @@ export class ToolExecutor {
|
|
|
1250
1326
|
// result that was cut told the model to look.
|
|
1251
1327
|
const retained =
|
|
1252
1328
|
tool.name === 'read' && (await resolveRetainedOutput(this.outputDirectory(), value))
|
|
1253
|
-
const outside = retained ? undefined : await pathOutsideRoots(
|
|
1329
|
+
const outside = retained ? undefined : await pathOutsideRoots(this.rootsNow(), value)
|
|
1254
1330
|
if (outside !== undefined) outsidePaths = [outside]
|
|
1255
1331
|
}
|
|
1256
1332
|
}
|
|
@@ -1307,7 +1383,7 @@ export class ToolExecutor {
|
|
|
1307
1383
|
protectedPaths: readonly string[]
|
|
1308
1384
|
dangerousRemoval?: string
|
|
1309
1385
|
}> {
|
|
1310
|
-
const roots =
|
|
1386
|
+
const roots = this.rootsNow()
|
|
1311
1387
|
const protectedPaths: string[] = []
|
|
1312
1388
|
let outsidePaths: readonly string[] = []
|
|
1313
1389
|
let removal: string | undefined
|
|
@@ -1376,6 +1452,7 @@ export class ToolExecutor {
|
|
|
1376
1452
|
}
|
|
1377
1453
|
const calls = new Map((previous as OwnedPreparedToolBatch).calls)
|
|
1378
1454
|
const escalations = new Map((previous as OwnedPreparedToolBatch).escalations)
|
|
1455
|
+
const scratchpadWrites = new Set((previous as OwnedPreparedToolBatch).scratchpadWrites)
|
|
1379
1456
|
const earlierPaths: string[] = []
|
|
1380
1457
|
for (const toolCall of response.message.toolCalls ?? []) {
|
|
1381
1458
|
if (changedCallIds.has(toolCall.id)) {
|
|
@@ -1384,21 +1461,25 @@ export class ToolExecutor {
|
|
|
1384
1461
|
earlierPaths,
|
|
1385
1462
|
)
|
|
1386
1463
|
calls.set(toolCall.id, call)
|
|
1464
|
+
this.admitScratchpad(call)
|
|
1387
1465
|
const escalation = await this.escalationOrUnfinished(call)
|
|
1388
1466
|
if (escalation) escalations.set(toolCall.id, escalation)
|
|
1389
1467
|
else escalations.delete(toolCall.id)
|
|
1468
|
+
if (await this.writesScratchpad(call, escalation)) scratchpadWrites.add(toolCall.id)
|
|
1469
|
+
else scratchpadWrites.delete(toolCall.id)
|
|
1390
1470
|
}
|
|
1391
1471
|
const prepared = calls.get(toolCall.id)
|
|
1392
1472
|
const path = prepared ? inputPathOf(prepared) : undefined
|
|
1393
1473
|
if (path !== undefined) earlierPaths.push(path)
|
|
1394
1474
|
}
|
|
1395
1475
|
this.config.abortSignal.throwIfAborted()
|
|
1396
|
-
return this.publishPreparedBatch(calls, escalations)
|
|
1476
|
+
return this.publishPreparedBatch(calls, escalations, scratchpadWrites)
|
|
1397
1477
|
}
|
|
1398
1478
|
|
|
1399
1479
|
private publishPreparedBatch(
|
|
1400
1480
|
calls: ReadonlyMap<string, PreparedDirectCall>,
|
|
1401
1481
|
escalations: ReadonlyMap<string, ToolCallEscalation>,
|
|
1482
|
+
scratchpadWrites: ReadonlySet<string>,
|
|
1402
1483
|
): PreparedToolBatch {
|
|
1403
1484
|
const reviewCalls = [...calls.values()]
|
|
1404
1485
|
.filter(
|
|
@@ -1414,12 +1495,14 @@ export class ToolExecutor {
|
|
|
1414
1495
|
input: call.input,
|
|
1415
1496
|
...(escalation ? { escalation } : {}),
|
|
1416
1497
|
...(hookPermission ? { hookPermission } : {}),
|
|
1498
|
+
...(scratchpadWrites.has(call.toolCall.id) ? { scratchpadWrite: true as const } : {}),
|
|
1417
1499
|
}
|
|
1418
1500
|
})
|
|
1419
1501
|
const batch: OwnedPreparedToolBatch = Object.freeze({
|
|
1420
1502
|
reviewCalls: Object.freeze(reviewCalls),
|
|
1421
1503
|
calls: new Map(calls),
|
|
1422
1504
|
escalations: new Map(escalations),
|
|
1505
|
+
scratchpadWrites: new Set(scratchpadWrites),
|
|
1423
1506
|
})
|
|
1424
1507
|
this.preparedBatches.add(batch)
|
|
1425
1508
|
return batch
|
|
@@ -2032,6 +2115,7 @@ export class ToolExecutor {
|
|
|
2032
2115
|
} catch {
|
|
2033
2116
|
needsApproval = true
|
|
2034
2117
|
}
|
|
2118
|
+
this.admitScratchpad(preparedCall)
|
|
2035
2119
|
const nestedEscalation = await this.escalationOrUnfinished(preparedCall)
|
|
2036
2120
|
if (nestedEscalation || needsApproval || (gateResult && gateResult.decision !== 'allow')) {
|
|
2037
2121
|
const reason = nestedEscalation
|
|
@@ -2276,8 +2360,8 @@ export class ToolExecutor {
|
|
|
2276
2360
|
sessionId: this.config.sessionId,
|
|
2277
2361
|
turnId: this.config.turnId,
|
|
2278
2362
|
workingDirectory: this.config.workingDirectory,
|
|
2279
|
-
...(this.
|
|
2280
|
-
? { additionalDirectories: this.
|
|
2363
|
+
...(this.additionalDirectoriesNow()?.length
|
|
2364
|
+
? { additionalDirectories: this.additionalDirectoriesNow() }
|
|
2281
2365
|
: {}),
|
|
2282
2366
|
...(this.config.authorizationGate
|
|
2283
2367
|
? {
|
|
@@ -377,6 +377,20 @@ export interface QueryParams {
|
|
|
377
377
|
* before; `0` stops at the first cut-off.
|
|
378
378
|
*/
|
|
379
379
|
maxOutputContinuations?: number
|
|
380
|
+
/**
|
|
381
|
+
* What the turn does when the model declines to answer (its provider
|
|
382
|
+
* finished the reply as a refusal or a safety block).
|
|
383
|
+
*
|
|
384
|
+
* `'auto'` (the default) re-runs the refused request once on another model
|
|
385
|
+
* of the same provider (the one the provider recommends, or the one its
|
|
386
|
+
* own fallback order names) and keeps that model for the rest of the turn;
|
|
387
|
+
* a `model_refusal` event names the swap so a host can keep it for later
|
|
388
|
+
* turns. `'off'` never changes the model: the turn tries once more on the
|
|
389
|
+
* same model only when the refused reply had partial output, and otherwise
|
|
390
|
+
* settles with stop reason `'refused'`. A refused reply is never added to
|
|
391
|
+
* the history sent next.
|
|
392
|
+
*/
|
|
393
|
+
refusalFallback?: 'auto' | 'off'
|
|
380
394
|
|
|
381
395
|
/**
|
|
382
396
|
* Model-visible size cap for a single tool result. Over-budget output is
|
|
@@ -1528,6 +1542,17 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
|
|
|
1528
1542
|
// pre-approves ends with the turn. Shared by the executor, where the
|
|
1529
1543
|
// `skill` tool records a grant, and the review phase, which reads it.
|
|
1530
1544
|
const skillGrants = new SkillGrantSet()
|
|
1545
|
+
// The session's own scratch folder, a root of the file tools once a call
|
|
1546
|
+
// names it. Not for a sandboxed turn (it is not mounted there), and not
|
|
1547
|
+
// for a session whose state is not on disk.
|
|
1548
|
+
let scratchpadDirectory: string | undefined
|
|
1549
|
+
if (!params.sandboxProvider) {
|
|
1550
|
+
try {
|
|
1551
|
+
scratchpadDirectory = ctx.storage.paths?.scratchpadPath(ctx.sessionId)
|
|
1552
|
+
} catch {
|
|
1553
|
+
scratchpadDirectory = undefined
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1531
1556
|
const toolExecutor = ToolingBootstrap.init(
|
|
1532
1557
|
{
|
|
1533
1558
|
skillGrants,
|
|
@@ -1541,6 +1566,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
|
|
|
1541
1566
|
...(params.additionalDirectories?.length
|
|
1542
1567
|
? { additionalDirectories: params.additionalDirectories }
|
|
1543
1568
|
: {}),
|
|
1569
|
+
...(scratchpadDirectory ? { scratchpadDirectory } : {}),
|
|
1544
1570
|
...(params.outsideRootAccess ? { outsideRootAccess: params.outsideRootAccess } : {}),
|
|
1545
1571
|
...(params.sandboxEscape ? { sandboxEscape: params.sandboxEscape } : {}),
|
|
1546
1572
|
permissionMode: () => ctx.permissionMode.current,
|
|
@@ -1883,6 +1909,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
|
|
|
1883
1909
|
...(params.maxOutputContinuations !== undefined
|
|
1884
1910
|
? { maxOutputContinuations: params.maxOutputContinuations }
|
|
1885
1911
|
: {}),
|
|
1912
|
+
...(params.refusalFallback !== undefined ? { refusalFallback: params.refusalFallback } : {}),
|
|
1886
1913
|
...(params.inboundMessages ? { inboundMessages: params.inboundMessages } : {}),
|
|
1887
1914
|
...(params.reminders ? { reminders: params.reminders } : {}),
|
|
1888
1915
|
...(params.durableInbound ? { durableInbound: params.durableInbound } : {}),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LLMProvider, ReasoningEffort, ThinkingConfig } from '../../../types/provider/index.js'
|
|
2
|
+
|
|
3
|
+
/** Lowest to highest, the order the effort menus are drawn in. */
|
|
4
|
+
const EFFORT_ORDER: readonly ReasoningEffort[] = [
|
|
5
|
+
'none',
|
|
6
|
+
'minimal',
|
|
7
|
+
'low',
|
|
8
|
+
'medium',
|
|
9
|
+
'high',
|
|
10
|
+
'xhigh',
|
|
11
|
+
'max',
|
|
12
|
+
'ultra',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The effort to send to `model` when the turn was configured for another one.
|
|
17
|
+
*
|
|
18
|
+
* A level the model does not offer is rejected by some drivers, so a request
|
|
19
|
+
* moved to a different model (a refusal fallback, a live model pick) carries
|
|
20
|
+
* the nearest level that model supports: the next one down when there is one,
|
|
21
|
+
* otherwise the lowest one above. The menu comes from the provider, never from
|
|
22
|
+
* a copy here. A provider that cannot say what the model offers, or a level
|
|
23
|
+
* the model already offers, leaves the effort exactly as configured.
|
|
24
|
+
*/
|
|
25
|
+
export function effortForModel(
|
|
26
|
+
provider: LLMProvider,
|
|
27
|
+
model: string,
|
|
28
|
+
effort: ReasoningEffort,
|
|
29
|
+
thinking?: ThinkingConfig,
|
|
30
|
+
): ReasoningEffort {
|
|
31
|
+
let menu: readonly ReasoningEffort[] | undefined
|
|
32
|
+
try {
|
|
33
|
+
menu = provider.reasoningEffortLevelsFor
|
|
34
|
+
? provider.reasoningEffortLevelsFor(model, thinking)
|
|
35
|
+
: provider.effortLevelsFor?.(model, thinking)
|
|
36
|
+
} catch {
|
|
37
|
+
return effort
|
|
38
|
+
}
|
|
39
|
+
if (!menu || menu.length === 0 || menu.includes(effort)) return effort
|
|
40
|
+
const rank = (level: ReasoningEffort) => EFFORT_ORDER.indexOf(level)
|
|
41
|
+
const wanted = rank(effort)
|
|
42
|
+
if (wanted < 0) return effort
|
|
43
|
+
const ranked = menu.filter((level) => rank(level) >= 0).sort((a, b) => rank(a) - rank(b))
|
|
44
|
+
const below = ranked.filter((level) => rank(level) < wanted)
|
|
45
|
+
return below[below.length - 1] ?? ranked[0] ?? effort
|
|
46
|
+
}
|