Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

# Conflicts:
#	packages/core/agent-loop/README.md
This commit is contained in:
Tianyi Cui
2026-07-14 10:35:15 +08:00
13 changed files with 105 additions and 25 deletions

View File

@@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve
## Wiring
`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server verifies that the live child is owned by the exact delegating parent, then counts local starts by provider/id and that parent carrier because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported even when their parent-scoped run id collides with an unrelated local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`.
`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server recognizes a live child through either exact delegating-parent runtime ownership or matching durable `parentSession` lineage, then counts local starts by provider/id and that parent carrier because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported even when their parent-scoped run id collides with an unrelated local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`.
## Config

View File

@@ -66,6 +66,15 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
return carrierKeyOf(carrier) as Agent
}
/** Whether the live id names a local child related to this exact delegating parent. */
function isLocalChild(ctx: Context, id: SessionId, parent: Agent): boolean {
const child = ctx.agents.get(id)
return child !== undefined && (
ctx.agents.isOwnedBy(id, parent)
|| child.session.header.parentSession === parent.session.id
)
}
/**
* The SDK server over a booted harness context. Constructing it subscribes to
* session and subagent lifecycle events, forwarding durable session
@@ -104,13 +113,14 @@ export class HarnessSdkServer {
childSessionId: String(session.id),
})
}))
// In-process providers publish the child before start. Count those starts by
// the exact delegating-parent carrier so later completions remain local after
// child disposal and reused ids need no settlement-order assumption.
// In-process providers publish the child before start. Count starts related
// by exact runtime ownership or durable parent lineage so provider-owned
// roots remain local, completions survive child disposal, and reused ids
// need no settlement-order assumption.
const localRuns = this.localRuns
this.disposers.push(ctx.on('subagent/start', function (this: Scoped<SubagentService>, info: SubagentRunInfo) {
const parent = subagentParentOf(this)
if (!ctx.agents.isOwnedBy(info.id, parent)) return
if (!isLocalChild(ctx, info.id, parent)) return
const providerRuns = localRuns.get(info.provider) ?? new Map<SessionId, Map<Agent, number>>()
const parentRuns = providerRuns.get(info.id) ?? new Map<Agent, number>()
parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1)
@@ -130,9 +140,9 @@ export class HarnessSdkServer {
}
// This protocol reports LOCAL child sessions. A lineage-bearing child
// has the session/created-driven start notification above. A remote run
// has neither a cached owned start nor a live child owned by this exact
// has neither a cached local start nor a live child related to this
// parent; an unrelated local agent with the same id never makes it local.
if (pendingCount === undefined && !ctx.agents.isOwnedBy(info.id, parent)) return
if (pendingCount === undefined && !isLocalChild(ctx, info.id, parent)) return
transport.notify('subagent.finished', {
provider: info.provider,
agentId: String(info.id),

View File

@@ -277,11 +277,14 @@ describe('HarnessSdkServer', () => {
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
})
const handle = await parentHandle.agent.ctx.agents.create({
// A custom in-process provider may own its child at the provider/root
// scope while preserving durable parent lineage.
const handle = await ctx.agents.create({
sessionId: SessionId('child-session'),
meta: { cwd: storageDir, parentSession: SessionId('main') },
agentOptions: { model: 'deepseek' },
})
expect(ctx.agents.roots()).toContain(handle.agent)
const parentlessHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('parentless-child-session'),
meta: { cwd: storageDir },

View File

@@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; the UI's `main` text is only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
## The bin

View File

@@ -114,9 +114,8 @@ describe('dsh-stdio-agent app', () => {
const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too.
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.list()).toHaveLength(1)
await expect.poll(() => ctx.get('sessionPersistence')).toBeDefined()
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
await ctx.fiber.dispose()
})