Merge remote-tracking branch 'origin/fix/web-transcript-projection' into feat/manual-compact-single-lock

# Conflicts:
#	apps/cli/config/base.cordis.yml
#	apps/cli/cordis.yml
#	docs/event-producer-consumer.md
#	examples/package.json
#	examples/tui-agent/README.i18n.yaml
#	examples/tui-agent/README.md
#	examples/tui-agent/README.zh.md
#	pnpm-lock.yaml
This commit is contained in:
Hypatia May
2026-07-30 20:09:23 +08:00
271 changed files with 4393 additions and 3153 deletions

View File

@@ -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)

View File

@@ -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, {