fix(llm): isolate retry policy histories

This commit is contained in:
Turtle
2026-07-25 15:38:58 +08:00
parent 38ce422f71
commit efc725b7e4
31 changed files with 666 additions and 28 deletions

View File

@@ -67,6 +67,8 @@ export interface Config {
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */
goals?: agentCore.GoalConfig | false
/** Invalid at app level; configure `retryPolicy` under each provider. */
llmRetry?: never
}
// Each front door owns a complete, directly readable config schema; extracting
@@ -92,6 +94,8 @@ export const Config: z<Config> = z.object({
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
// Provider retryPolicy makes a top-level llmRetry invalid.
llmRetry: z.never(),
})
/* jscpd:ignore-end */

View File

@@ -76,6 +76,16 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
}
describe('dsh-acp-demo composition', () => {
it('rejects app-level llmRetry config through plugin validation', async () => {
const ctx = new Context()
await expect(ctx.plugin(acpAgent, {
provider: 'mock',
model: 'mock',
workspaceContext: false,
llmRetry: { maxTransientRetries: 2 },
} as never)).rejects.toThrow(/llmRetry/)
})
it('brings up the spine + persistence + the ACP bridge', async () => {
const ctx = await mount({
provider: 'mock',

View File

@@ -207,6 +207,20 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
expect(code).not.toBe(0)
expect(stderr).toContain('config file not found')
}, 30_000)
it('rejects legacy app-level llmRetry through the published Loader path', async () => {
consumer = await makeConsumer()
const configPath = join(consumer, 'cordis.yml')
const config = await readFile(configPath, 'utf8')
await writeFile(configPath, config.replace(
' workspaceContext: false',
' workspaceContext: false\n llmRetry:\n maxTransientRetries: 2',
))
const { code, stderr } = await runBinExpectingExit('./cordis.yml', consumer)
expect(code).not.toBe(0)
expect(stderr).toContain('llmRetry')
}, 30_000)
})
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */

View File

@@ -112,6 +112,8 @@ export interface Config {
invariants?: InvariantConfig
/** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */
goals?: GoalConfig | false
/** Invalid at bundle level; configure `retryPolicy` under each provider. */
llmRetry?: never
}
/** The skill config schema exported for app packages that forward `skills`. */
@@ -152,6 +154,9 @@ export const Config = z.intersect([
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
invariants: InvariantService.Config,
goals: z.union([z.const(false), GoalConfigSchema]),
// Schemastery preserves unknown object properties. A top-level llmRetry is
// known-but-impossible because provider retryPolicy owns this configuration.
llmRetry: z.never(),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'sessionTitle' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals'>>,
]) as unknown as z<Config>

View File

@@ -594,6 +594,14 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(agentCore.name).toBe('agent-spine-demo')
})
it('rejects bundle-level llmRetry config through plugin validation', async () => {
const ctx = new Context()
await expect(ctx.plugin(agentCore, {
workspaceContext: false,
llmRetry: { maxTransientRetries: 2 },
} as never)).rejects.toThrow(/llmRetry/)
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
// drop `name`/`Config`. Apps import the bundle directly, so this is its Loader-shape guard.

View File

@@ -52,6 +52,8 @@ export interface Config {
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
/** Invalid at app level; configure `retryPolicy` under each provider. */
llmRetry?: never
}
// Each front door keeps a complete Loader schema so its deployment contract is
@@ -73,6 +75,8 @@ export const Config: z<Config> = z.object({
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
// Provider retryPolicy makes a top-level llmRetry invalid.
llmRetry: z.never(),
})
/* jscpd:ignore-end */

View File

@@ -192,6 +192,39 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
}
}, 30_000)
it('rejects legacy app-level llmRetry through the published Loader path', async () => {
consumer = await makeConsumer()
const configPath = join(consumer, 'cordis.yml')
const config = await readFile(configPath, 'utf8')
await writeFile(configPath, config.replace(
' workspaceContext: false',
' workspaceContext: false\n llmRetry:\n maxTransientRetries: 2',
))
const result = await runBuiltBin(consumer, ['--config', './cordis.yml', 'task'])
expect(result.code).not.toBe(0)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('llmRetry')
}, 30_000)
it('rejects legacy bundle-level llmRetry when the published spine is loaded directly', async () => {
consumer = await makeConsumer()
await writeFile(join(consumer, 'cordis.yml'), [
'- id: spine',
" name: '@deepseek-ai/dsh-agent-spine-demo'",
' config:',
' workspaceContext: false',
' llmRetry:',
' maxTransientRetries: 2',
'',
].join('\n'))
const result = await runBuiltBin(consumer, ['--config', './cordis.yml', 'task'])
expect(result.code).not.toBe(0)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('llmRetry')
}, 30_000)
describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => {
it.each([
['SIGINT', 130],

View File

@@ -173,6 +173,16 @@ afterEach(async () => {
})
describe('parseCliArgs', () => {
it('rejects app-level llmRetry config through plugin validation', async () => {
const ctx = new Context()
await expect(ctx.plugin(cliDemo, {
provider: 'mock',
model: 'mock',
workspaceContext: false,
llmRetry: { maxTransientRetries: 2 },
} as never)).rejects.toThrow(/llmRetry/)
})
it('parses defaults, explicit options, spaces, and an option-like task after --', () => {
expect(parseCliArgs(['task with spaces'])).toEqual({
kind: 'run', configPath: './cordis.yml', outputFormat: 'text', task: 'task with spaces',

View File

@@ -82,6 +82,8 @@ export interface Config {
resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
/** Invalid at app level; configure `retryPolicy` under each provider. */
llmRetry?: never
}
export const Config: z<Config> = z.object({
@@ -106,6 +108,8 @@ export const Config: z<Config> = z.object({
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
resumeSessionId: z.string(),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
// Provider retryPolicy makes a top-level llmRetry invalid.
llmRetry: z.never(),
})
/* jscpd:ignore-end */

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import type { Context } from 'cordis'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as tuiAgent from '../src/index.ts'
@@ -21,6 +21,16 @@ function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall
}
describe('dsh-tui-demo app', () => {
it('rejects app-level llmRetry config through plugin validation', async () => {
const ctx = new Context()
await expect(ctx.plugin(tuiAgent, {
provider: 'mock',
model: 'mock',
workspaceContext: false,
llmRetry: { maxTransientRetries: 2 },
} as never)).rejects.toThrow(/llmRetry/)
})
it('composes the TUI cluster around one fresh exact session identity', () => {
const { ctx, calls } = recordingContext()
tuiAgent.composeTuiApp(ctx, {