Merge branch 'worktree-llm-dynamic-config' into worktree-llm-web-config
# Conflicts: # apps/cli/cordis.yml # apps/cli/package.json # apps/cli/tests/tui-keyless-smoke.e2e.ts # apps/web/tests/details-session-lifecycle.e2e.ts # apps/web/tests/snapshots/code-mode-round/ui.expected.md # apps/web/tests/snapshots/cordis-tool-round/ui.expected.md # apps/web/tests/snapshots/fresh-round-trip/ui.expected.md # apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md # apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md # apps/web/tests/snapshots/live-interactions/cancel.expected.md # apps/web/tests/snapshots/live-interactions/error-auth.expected.md # apps/web/tests/snapshots/live-interactions/retry.expected.md # apps/web/tests/snapshots/message-actions/ui.expected.md # apps/web/tests/snapshots/question-composer/answered.expected.md # apps/web/tests/snapshots/seeded-history/ui.expected.md # apps/web/tests/snapshots/steering/mid-steer.expected.md # apps/web/tests/snapshots/steering/settled.expected.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/user/guide/config.i18n.yaml # docs/user/guide/config.md # docs/user/guide/config.zh.md # docs/user/guide/index.i18n.yaml # docs/user/guide/index.md # docs/user/guide/index.zh.md # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/cordis-agent/cordis.yml # examples/cordis-agent/tests/cordis-tools.e2e.ts # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/tui-agent/code-mode.cordis.yml # examples/tui-agent/cordis.yml # packages/examples/tui-demo/README.md # packages/examples/tui-demo/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/pty/tool-bash-persistent/README.i18n.yaml # packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt # packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt # pnpm-lock.yaml # scripts/snapshots/python-sdk-single-exe/advanced/result.json # scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl # scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl # scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl
This commit is contained in:
@@ -134,6 +134,15 @@ interface PreparedAgent {
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
/**
|
||||
* Launcher-owned exact session identities for configured agents, keyed by
|
||||
* the agent's config `id` and set with `ctx.provide()` before any Loader
|
||||
* entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
|
||||
* owns identity because only it knows whether the session already exists,
|
||||
* while the `cordis.yml` row keeps the model route as ordinary patchable
|
||||
* config. An entry with no matching key keeps its configured identity.
|
||||
*/
|
||||
configuredAgentIdentities?: ConfiguredAgentIdentities
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
@@ -151,6 +160,53 @@ declare module 'cordis' {
|
||||
|
||||
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
||||
|
||||
/**
|
||||
* One launcher-selected session identity for a configured agent. `resume`
|
||||
* distinguishes rehydrating existing persisted history from creating the
|
||||
* session fresh under that exact id, which the two config keys express as
|
||||
* `resumeSessionId` and `sessionId`.
|
||||
*/
|
||||
export interface LauncherAgentIdentity {
|
||||
/** Exact session id to create fresh or resume. */
|
||||
id: SessionId
|
||||
/** Resume existing persisted history instead of creating the session fresh. */
|
||||
resume: boolean
|
||||
}
|
||||
|
||||
/** Launcher-selected identities keyed by the configured agent's `id`. */
|
||||
export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
|
||||
* configured agents' session identities without a config key, so an overlay
|
||||
* repointing the row's model route cannot drop them.
|
||||
*/
|
||||
export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
|
||||
|
||||
/**
|
||||
* Apply launcher-owned identities over the configured agents, replacing both
|
||||
* identity keys for every entry the launcher named so a config-supplied
|
||||
* identity can never survive alongside a launcher-supplied one.
|
||||
* @param agents - the configured agent entries.
|
||||
* @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
|
||||
* @returns the entries with launcher-owned identities applied.
|
||||
*/
|
||||
function applyLauncherIdentities(
|
||||
agents: Config['agents'],
|
||||
identities: ConfiguredAgentIdentities | undefined,
|
||||
): Config['agents'] {
|
||||
if (identities === undefined) return agents
|
||||
return agents.map((agent) => {
|
||||
const identity = identities[agent.id]
|
||||
if (identity === undefined) return agent
|
||||
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
|
||||
return identity.resume
|
||||
? { ...rest, resumeSessionId: identity.id }
|
||||
: { ...rest, sessionId: identity.id }
|
||||
})
|
||||
}
|
||||
|
||||
/** Agent-loop plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -220,6 +276,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
super(ctx, 'agentLoop')
|
||||
this.config = {
|
||||
...config,
|
||||
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
|
||||
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
|
||||
}
|
||||
validateConfiguredAgents(this.config.agents)
|
||||
|
||||
@@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -36,6 +36,26 @@ async function makeCoreContext(): Promise<Context> {
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
it('applies launcher identities by configured id without changing unmatched entries', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, {
|
||||
fresh: { id: SessionId('launcher-fresh'), resume: false },
|
||||
resumed: { id: SessionId('launcher-resumed'), resume: true },
|
||||
})
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [
|
||||
{ id: 'fresh', sessionId: SessionId('config-fresh'), model: 'mock' },
|
||||
{ id: 'resumed', sessionId: SessionId('config-resumed'), model: 'mock' },
|
||||
{ id: 'unchanged', sessionId: SessionId('config-unchanged'), model: 'mock' },
|
||||
],
|
||||
})
|
||||
expect(ctx.agents.get(SessionId('launcher-fresh'))?.session.id).toBe('launcher-fresh')
|
||||
expect(ctx.agents.get(SessionId('launcher-resumed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-resumed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-unchanged'))?.session.id).toBe('config-unchanged')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an empty exact id before publishing an agent', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
await expect(ctx.plugin(AgentLoop, {
|
||||
|
||||
@@ -1221,8 +1221,9 @@ describe('agent loop', () => {
|
||||
|
||||
const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
|
||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||
// event-by-event identity of types
|
||||
expect(replayed.events.map(e => e.type)).toEqual(
|
||||
// event-by-event identity of types over the inherited prefix
|
||||
expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual(
|
||||
agent.session.events.map(e => e.type))
|
||||
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -283,7 +283,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(sessionId)
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||
// The two persisted events plus the end-seed marker.
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(3)
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
@@ -585,7 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
|
||||
// The resumed session carries the prior history…
|
||||
expect(a2.session.id).toBe('sess-resume')
|
||||
expect(a2.session.events.length).toBe(events1.length)
|
||||
// …followed by one end-seed event marking the constructor seed.
|
||||
expect(a2.session.events.length).toBe(events1.length + 1)
|
||||
expect(a2.session.firstLiveSeq).toBe(events1.length)
|
||||
expect(a2.session.events.at(-1)?.type).toBe('session/end-seed')
|
||||
const replay = new Session(SessionId('replay'), events1)
|
||||
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user