Merge branch 'master' into worktree/schedule-conversational-after
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
|
||||
README.md: 889fd74671eddc202b814cdf2749069ec3cea02c
|
||||
README.zh.md: b65b5334d735a1e0b51fa517ce41c0c953f87cf7
|
||||
README.md: 13f79b9e70bfb658c459240c97acf39025df2ac0
|
||||
README.zh.md: ad8fc9478eb45620cee303eb7295b6b3fe8b0ed3
|
||||
@@ -49,7 +49,7 @@ interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`; it is also the whole of the `agent-loop` Settings section, so a user layer over this entry caps the next tool group without a restart, and a value that is not a positive integer is refused at the write rather than at that group. `agents` is deliberately absent from that section — it is consumed once when the service starts, so a stored change could only look like it had an effect. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
|
||||
### Internal concrete driver
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。
|
||||
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`;它同时也是 `agent-loop` Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用,而非正整数的值会在写入时被拒绝,而不是到那一组时才失败。`agents` 刻意不在该段中——它在服务启动时被消费一次,所以存储的改动只会看起来生效。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。
|
||||
|
||||
### 包内部具体驱动器
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
@@ -54,6 +55,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -232,6 +233,24 @@ function applyLauncherIdentities(
|
||||
})
|
||||
}
|
||||
|
||||
/** Settings namespace carrying the tool-call parallelism a user owns. */
|
||||
export const AGENT_LOOP_SETTINGS_NAMESPACE = settingsNamespace('agent-loop')
|
||||
|
||||
/**
|
||||
* The agent-loop fields a user owns. Deliberately a strict subset of
|
||||
* {@link Config}: `agents` is a boot-time composition array consumed once when
|
||||
* the service starts, so a stored change could only look like it had an effect.
|
||||
*/
|
||||
export interface AgentLoopSettings {
|
||||
/** Maximum parallel-safe calls in flight per agent step. */
|
||||
maxParallelToolCalls: number
|
||||
}
|
||||
|
||||
/** Schema of the agent-loop settings section. */
|
||||
export const AGENT_LOOP_SETTINGS_SCHEMA: z<AgentLoopSettings> = z.object({
|
||||
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
|
||||
})
|
||||
|
||||
/** Agent-loop plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -299,11 +318,31 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'agentLoop')
|
||||
const entry: AgentLoopSettings = {
|
||||
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
|
||||
}
|
||||
let source: () => AgentLoopSettings = () => entry
|
||||
this.config = {
|
||||
...config,
|
||||
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
|
||||
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
|
||||
// Read through on every scheduler decision: `tool-calls.ts` destructures
|
||||
// this at the start of each group, so a committed change caps the next
|
||||
// group without disturbing the one in flight.
|
||||
get maxParallelToolCalls() {
|
||||
return source().maxParallelToolCalls
|
||||
},
|
||||
}
|
||||
installSettingsSection(ctx, AGENT_LOOP_SETTINGS_NAMESPACE, AGENT_LOOP_SETTINGS_SCHEMA, entry, {
|
||||
// The schema admits any integer above zero; `resolveMaxParallelToolCalls`
|
||||
// owns the whole rule, so refusing here keeps the running scheduler on
|
||||
// its last good cap instead of failing at the next tool group.
|
||||
validate: value => void resolveMaxParallelToolCalls(value.maxParallelToolCalls),
|
||||
setSource: (current) => {
|
||||
source = current
|
||||
},
|
||||
// Nothing is derived from the cap: the getter above is the only reader.
|
||||
onChange: () => {},
|
||||
})
|
||||
validateConfiguredAgents(this.config.agents)
|
||||
this.ownership = new FactoryOwnership(ctx.fiber)
|
||||
this.runtime = { ctx }
|
||||
|
||||
@@ -336,7 +336,10 @@ describe('config-driven session id', () => {
|
||||
|
||||
const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)')
|
||||
expect(resumeEffect?.children.map(child => child.label)).toEqual(['ctx.plugin()'])
|
||||
expect(loopFiber.getEffects().filter(effect => effect.label === 'ctx.plugin()')).toEqual([])
|
||||
// Exactly one plugin effect sits at the fiber's own level — the optional
|
||||
// settings wiring, whose `ctx.inject` cordis labels like any other plugin.
|
||||
// A resumed agent joining it there is the regression this pins.
|
||||
expect(loopFiber.getEffects().filter(effect => effect.label === 'ctx.plugin()')).toHaveLength(1)
|
||||
|
||||
await loopFiber.dispose()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/** The `agent-loop` settings section layered over the composition entry. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { Settings } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import AgentLoop, { AGENT_LOOP_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; loopFiber: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const settingsFiber = ctx.plugin(MemorySettings)
|
||||
await settingsFiber.await()
|
||||
const loopFiber = ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 4 })
|
||||
await loopFiber.await()
|
||||
return { ctx, settingsFiber, loopFiber }
|
||||
}
|
||||
|
||||
describe('agent-loop settings section', () => {
|
||||
it('layers the stored parallel cap over the composition entry', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(4)
|
||||
|
||||
await bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 1 })
|
||||
|
||||
expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(1)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a non-positive cap at the write', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await expect(bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 0 }))
|
||||
.rejects.toThrow()
|
||||
|
||||
expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(4)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('never offers the composed agents array to the settings document', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
const descriptor = bench.ctx.settings.describe().find(row => String(row.ns) === 'agent-loop')
|
||||
|
||||
expect(Object.keys(descriptor?.value as object)).toEqual(['maxParallelToolCalls'])
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps serving the composed agents array to its own consumers', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 2 })
|
||||
|
||||
expect(bench.ctx.agentLoop.config.agents).toEqual([])
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the settings provider detaches', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 1 })
|
||||
expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(1)
|
||||
|
||||
await bench.settingsFiber.dispose()
|
||||
|
||||
expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(4)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('releases the namespace when the service unloads', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('agent-loop')
|
||||
|
||||
await bench.loopFiber.dispose()
|
||||
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('agent-loop')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,9 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user