Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
This commit is contained in:
@@ -4,7 +4,7 @@ Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_ha
|
||||
|
||||
## Wiring
|
||||
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId` on `session/prompt` and demuxes `subagent/end` through the registry. If `initialize.model` lacks a registered adapter, it mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`; a config-registered adapter wins. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair and demuxes `subagent/end` through the registry. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { JsonRpcTransportPeer } from './transport.ts'
|
||||
export interface InitializeParams {
|
||||
/** Working directory recorded on every SDK-created session's header. */
|
||||
cwd: string
|
||||
/** Provider route every SDK-created agent runs on. */
|
||||
provider: string
|
||||
/** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */
|
||||
model: string
|
||||
}
|
||||
@@ -65,6 +67,7 @@ interface SubagentRecord {
|
||||
*/
|
||||
export class HarnessSdkServer {
|
||||
private cwd = process.cwd()
|
||||
private provider = 'deepseek'
|
||||
private model = 'deepseek'
|
||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||
private readonly sessions = new Map<string, SessionRecord>()
|
||||
@@ -123,16 +126,18 @@ export class HarnessSdkServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record cwd and model, mounting the DeepSeek adapter only when the config
|
||||
* registered no adapter for that model.
|
||||
* Record cwd and provider/model, mounting the DeepSeek adapter only when the
|
||||
* `deepseek` provider route has no configured owner.
|
||||
* @param params - the SDK handshake parameters.
|
||||
* @returns the server identity for the handshake.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
this.cwd = resolve(params.cwd)
|
||||
this.provider = params.provider
|
||||
this.model = params.model
|
||||
if (!this.llmFiber && !this.hasAdapterFor(this.model)) {
|
||||
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] })
|
||||
if (!this.hasAdapterFor(this.provider)) {
|
||||
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
|
||||
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
|
||||
}
|
||||
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
|
||||
}
|
||||
@@ -242,7 +247,7 @@ export class HarnessSdkServer {
|
||||
const handle = await this.ctx.agents.create({
|
||||
sessionId: SessionId(sessionId),
|
||||
meta: { cwd: this.cwd },
|
||||
agentOptions: { model: this.model },
|
||||
agentOptions: { provider: this.provider, model: this.model },
|
||||
})
|
||||
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
|
||||
this.sessions.set(sessionId, rec)
|
||||
@@ -254,7 +259,7 @@ export class HarnessSdkServer {
|
||||
return reason.kind === 'completed' ? 'ok' : 'error'
|
||||
}
|
||||
|
||||
private hasAdapterFor(model: string): boolean {
|
||||
return this.ctx.get('llm')?.models().includes(model) ?? false
|
||||
private hasAdapterFor(provider: string): boolean {
|
||||
return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ async function mountPlugin(
|
||||
options: { writeDelayMs?: number; failFlush?: boolean } = {},
|
||||
): Promise<ApplyHarness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(agentCore)
|
||||
await ctx.plugin(agentCore, { workspaceContext: false })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const harness = await mountPlugin(storageDir)
|
||||
try {
|
||||
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, model: 'apply-model' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'apply-model' } })
|
||||
|
||||
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
|
||||
expect(response).toEqual({
|
||||
@@ -175,7 +175,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
|
||||
const harness = await mountPlugin(storageDir)
|
||||
try {
|
||||
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, model: 'dsagent-model' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'dsagent-model' } })
|
||||
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
|
||||
|
||||
harness.send({
|
||||
@@ -236,7 +236,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
expect(harness.exits()).toEqual([0])
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
await settle()
|
||||
expect(harness.frames().length).toBe(before)
|
||||
} finally {
|
||||
@@ -257,7 +257,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
await settle()
|
||||
expect(harness.frames().length).toBe(before)
|
||||
} finally {
|
||||
@@ -281,7 +281,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
await harness.fiber.dispose()
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
await settle()
|
||||
expect(harness.frames().length).toBe(before)
|
||||
expect(harness.exits()).toEqual([])
|
||||
|
||||
@@ -59,7 +59,7 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[
|
||||
|
||||
async function makeHarness(storageDir: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(agentCore)
|
||||
await ctx.plugin(agentCore, { workspaceContext: false })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
@@ -108,6 +108,7 @@ describe('HarnessSdkServer', () => {
|
||||
|
||||
const init = await server.handleRequest('initialize', {
|
||||
cwd: storageDir,
|
||||
provider: 'deepseek',
|
||||
model: 'dsagent-model',
|
||||
}) as { serverInfo: { name: string } }
|
||||
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
|
||||
@@ -138,7 +139,7 @@ describe('HarnessSdkServer', () => {
|
||||
const orphanHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('orphan-session'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'dsagent-model' },
|
||||
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
|
||||
})
|
||||
orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }])
|
||||
await orphanHandle.agent.whenIdle()
|
||||
@@ -241,7 +242,7 @@ describe('HarnessSdkServer', () => {
|
||||
try {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
|
||||
await server.initialize({ cwd: storageDir, model: 'plain-model' })
|
||||
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'plain-model' })
|
||||
await server.prompt({
|
||||
sessionId: 'plain',
|
||||
contentBlocks: [{ type: 'text', text: 'hello' }],
|
||||
@@ -265,12 +266,12 @@ describe('HarnessSdkServer', () => {
|
||||
const parentHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('main'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('child-session'),
|
||||
meta: { cwd: storageDir, parentSession: SessionId('main') },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'spawn',
|
||||
@@ -311,17 +312,17 @@ describe('HarnessSdkServer', () => {
|
||||
parentHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('fallback-parent'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
handle = await ctx.agents.create({
|
||||
sessionId: SessionId('fallback-child-session'),
|
||||
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
failedHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('failed-child-session'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
const transport = new FakeTransport()
|
||||
const server = new HarnessSdkServer(ctx, transport)
|
||||
@@ -380,20 +381,20 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not re-register an LLM adapter that already exists', async () => {
|
||||
it('does not re-register an LLM adapter whose provider already has an owner', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['preinstalled-model'] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
try {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
const inspect = server as unknown as { hasAdapterFor(model: string): boolean }
|
||||
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
|
||||
|
||||
expect(inspect.hasAdapterFor('preinstalled-model')).toBe(true)
|
||||
expect(inspect.hasAdapterFor('missing-model')).toBe(false)
|
||||
await server.initialize({ cwd: storageDir, model: 'preinstalled-model' })
|
||||
expect(inspect.hasAdapterFor('deepseek')).toBe(true)
|
||||
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
|
||||
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'preinstalled-model' })
|
||||
|
||||
expect(ctx.get('llm')?.models().filter(model => model === 'preinstalled-model')).toEqual(['preinstalled-model'])
|
||||
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek')).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -401,17 +402,18 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('registers a missing model when an LLM service already exists', async () => {
|
||||
it('rejects a missing non-DeepSeek provider when an LLM service already exists', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['other-model'] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
try {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
|
||||
await server.initialize({ cwd: storageDir, model: 'new-model' })
|
||||
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
|
||||
.rejects.toThrow('no adapter registered for provider "private"')
|
||||
|
||||
expect(ctx.get('llm')?.models()).toEqual(expect.arrayContaining(['other-model', 'new-model']))
|
||||
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -512,15 +514,15 @@ describe('HarnessSdkServer', () => {
|
||||
const ctx = {
|
||||
on: vi.fn(() => () => undefined),
|
||||
agents: { create, get: () => undefined },
|
||||
get: () => ({ models: () => ['model'] }),
|
||||
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
||||
initialize(params: { cwd: string; model: string }): Promise<unknown>
|
||||
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
|
||||
getOrCreateSession(sessionId: string): Promise<unknown>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
|
||||
await server.initialize({ cwd: '.', model: 'model' })
|
||||
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
|
||||
await server.getOrCreateSession('relative')
|
||||
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
|
||||
|
||||
Reference in New Issue
Block a user