Merge remote-tracking branch 'origin/master' into worktree/pr540-merge-master-20260723

This commit is contained in:
Tianyi Cui
2026-07-23 22:34:49 +08:00
675 changed files with 29770 additions and 6564 deletions

View File

@@ -32,7 +32,7 @@ The plugin registers the single provider route `deepseek`. A request selects it
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults.
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.

View File

@@ -118,14 +118,18 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
parameters: tool.parameters,
},
}))
// A short title budget must produce visible text; conversation and
// compaction calls continue to inherit the adapter's thinking defaults.
const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking
const reasoningEffort = options.purpose === 'session-title' ? undefined : defaults.reasoningEffort
return {
model: options.model,
messages,
stream: true,
stream_options: { include_usage: true },
...defaults.thinking !== undefined ? { thinking: { type: defaults.thinking } } : {},
...defaults.reasoningEffort !== undefined ? { reasoning_effort: defaults.reasoningEffort } : {},
...thinking !== undefined ? { thinking: { type: thinking } } : {},
...reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {},
...tools !== undefined && tools.length > 0 ? { tools } : {},
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},

View File

@@ -180,6 +180,15 @@ describe('serializeRequest', () => {
expect(wire.reasoning_effort).toBe('max')
})
it('disables thinking for session-title requests without changing adapter defaults', () => {
const wire = serializeRequest(
request({ messages: history, purpose: 'session-title' }),
{ thinking: 'enabled', reasoningEffort: 'max' },
)
expect(wire.thinking).toEqual({ type: 'disabled' })
expect(wire.reasoning_effort).toBeUndefined()
})
it('omits thinking fields when unset (provider default applies)', () => {
const wire = serializeRequest(request({ messages: history }))
expect(wire.thinking).toBeUndefined()

View File

@@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -166,7 +166,7 @@ describe('bounded transient retry policy', () => {
])
;({ ctx: context } = await harness(adapter))
let toolExecutions = 0
context.tools.register(defineTool({
context.tools.register(defineContentToolFixture({
name: 'danger',
description: 'must not run for a failed provider attempt',
parameters: {},

View File

@@ -39,7 +39,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
### Call configuration (`call-config.ts`)
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated.
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
### App attribution (`attribution.ts`)

View File

@@ -58,7 +58,8 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean {
}
/**
* Deep-freeze a value in place, guarding cycles, so later mutation throws.
* Deep-freeze a value in place with an iterative traversal, guarding cycles,
* so later mutation throws without imposing a JavaScript call-stack depth cap.
* {@link AbortSignal} objects are deliberately skipped because they are the
* request's live cancellation channel and freezing them breaks abort.
* @param value - the value to freeze in place.
@@ -66,16 +67,31 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean {
*/
export function deepFreeze<T>(value: T): T {
const seen = new WeakSet<object>()
const walk = (node: unknown): void => {
if (node === null || typeof node !== 'object') return
if (node instanceof AbortSignal) return
if (seen.has(node)) return
const pending: (
| { kind: 'visit'; node: unknown }
| { kind: 'property'; source: Record<string, unknown>; key: string }
)[] = [{ kind: 'visit', node: value }]
while (pending.length > 0) {
const task = pending.pop()
/* v8 ignore next -- the loop condition guarantees one pending task. */
if (task === undefined) continue
if (task.kind === 'property') {
pending.push({ kind: 'visit', node: task.source[task.key] })
continue
}
const node = task.node
if (node === null || typeof node !== 'object') continue
if (node instanceof AbortSignal) continue
if (seen.has(node)) continue
seen.add(node)
Object.freeze(node)
for (const key of Object.keys(node)) {
walk((node as Record<string, unknown>)[key])
const keys = Object.keys(node)
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index]
/* v8 ignore next -- the loop is bounded by the captured key count. */
if (key === undefined) continue
pending.push({ kind: 'property', source: node as Record<string, unknown>, key })
}
}
walk(value)
return value
}

View File

@@ -228,8 +228,8 @@ export interface GenerateOptions {
sessionId?: Branded<'SessionId'>
/**
* Provider-neutral classification for an auxiliary model call. Adapters may
* map the purpose to model-hidden transport metadata. Ordinary conversation
* requests leave it unset.
* map the purpose to model-hidden transport metadata or purpose-specific
* generation policy. Ordinary conversation requests leave it unset.
*/
purpose?: 'compaction'
purpose?: 'compaction' | 'session-title'
}

View File

@@ -56,6 +56,26 @@ describe('deepFreeze', () => {
deepFreeze(cyclic)
expect(Object.isFrozen(cyclic)).toBe(true)
})
it('freezes nesting deeper than the JavaScript call stack', () => {
const depth = 5_000
const root: unknown[] = []
let cursor = root
for (let index = 0; index < depth; index++) {
const child: unknown[] = []
cursor.push(child)
cursor = child
}
deepFreeze(root)
cursor = root
for (let index = 0; index < depth; index++) {
expect(Object.isFrozen(cursor)).toBe(true)
cursor = cursor[0] as unknown[]
}
expect(Object.isFrozen(cursor)).toBe(true)
})
})
describe('agent-loop request identity', () => {