refactor: unify agent and session identity

This commit is contained in:
Tianyi Cui
2026-07-14 01:59:21 +08:00
parent 7a33ee94be
commit 709cc7200e
105 changed files with 899 additions and 948 deletions

View File

@@ -3,11 +3,11 @@
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
* module), JSONL session
* persistence, and a pre-created `main` agent the UI drives.
* persistence, and one pre-created agent the UI drives under its `main` label.
*
* The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
* console (stdout is just the terminal) and always pre-creates the `main` agent
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
* console (stdout is just the terminal) and always pre-creates one agent the
* readline UI labels `main`. The leaf supplies the swappable backends (the LLM
* adapter, the bash executor), optional product tools, the optional `hmr`
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
* root, welcome banner).
@@ -41,7 +41,6 @@
import type { Context } from 'cordis'
import ConsoleExporter from '@cordisjs/plugin-logger-console'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
@@ -54,7 +53,7 @@ export const name = 'stdio-agent'
/**
* App config: the swappable per-demo values, each routed to where the app wires
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
* it. `model`/`resumeSessionId` configure the pre-created agent (through
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
@@ -63,7 +62,7 @@ export const name = 'stdio-agent'
* `welcome` is the UI banner.
*/
export interface Config {
/** Model name for the `main` agent (must have a registered adapter). */
/** Model name for the pre-created agent (must have a registered adapter). */
model: string
/** Deployment persona (the system-prompt plugin's `persona` config). */
persona?: string
@@ -78,7 +77,7 @@ export interface Config {
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
skills?: agentCore.SkillConfig
/**
* If set, the `main` agent RESUMES this persisted session id instead of
* If set, the pre-created agent RESUMES this persisted session id instead of
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
*/
@@ -103,9 +102,9 @@ export const Config: z<Config> = z.object({
/**
* Compose the spine with the stdio front door. The console logger comes first
* (infra), then the agent-core bundle pre-creating the `main` agent from this
* app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is
* (infra), then the agent-core bundle pre-creating one agent from this app's
* `model`/`resumeSessionId` with the deployment `persona`, then the JSONL
* backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is
* a leaf concern (see the module doc), so it is not mounted here.
*/
export function apply(ctx: Context, config: Config): void {
@@ -115,7 +114,7 @@ export function apply(ctx: Context, config: Config): void {
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
agents: [{
id: AgentId('main'),
id: 'main',
model: config.model,
cwd: process.cwd(),
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
@@ -125,5 +124,5 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' })
}

View File

@@ -19,7 +19,7 @@ import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -36,15 +36,10 @@ export const inject = ['agents', 'userInteraction']
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
// TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the
// precreated `main` agent; remove configurability and its config-only test.
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
agent: z.string().default('main'),
})
/**
@@ -98,23 +93,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Loader validation, so it must be self-contained rather than trusting the
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
const welcome = config.welcome ?? 'ready.'
const agentId = AgentId(config.agent ?? 'main')
const { input, output, exit } = runtime
// Render label lookup: the `turn/start` session event carries only the turn
// number, so to print the short agent id (`[main turn 1]`) we map the
// session's id to its agent's id. The session id is not reliably the agent id
// (a session can be created with an explicit/client-supplied id), so build the
// map from `agent/created` rather than parsing the id string. Seed from the
// registry's current agents first: an agent registered before this plugin
// installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
// reload of just this fiber) already fired its `agent/created`, so the live
// listener alone would miss it and its turns would fall back to the raw
// session id.
const labelBySession = new Map<string, string>()
for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
// This app owns exactly one pre-created agent. Hold the live object directly:
// its per-run id is intentionally fresh, while `main` remains only the
// terminal's fixed display label.
let target: Agent | undefined = ctx.agents.list()[0]
ctx.on('agent/created', (agent) => { target ??= agent })
ctx.on('agent/disposed', (agent) => {
if (target === agent) target = undefined
})
// Transcript rendering off the durable `session/event` feed — the assistant
// token stream, turn/step boundaries, tool activity, and todos all come from
@@ -136,7 +124,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
output.write(chunk.text)
}
} else if (event.type === 'turn/start') {
const label = labelBySession.get(session.header.id) ?? session.header.id
const label = target?.session === session ? 'main' : session.id
output.write(`\n[${label} turn ${event.data.turn}] `)
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
@@ -187,7 +175,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Work submitted: wait until a turn has run and the agent is idle.
if (submittedWork) {
if (!sawRunning) return
const agent = ctx.agents.get(agentId)
const agent = target
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit. The handle is tracked so the
@@ -201,7 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject.id !== agentId) return
if (subject !== target) return
if (status === 'running') sawRunning = true
if (status === 'idle') maybeExit()
})
@@ -354,9 +342,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
const agent = target
if (!agent) {
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
ctx.logger.error('ui-stdio: main agent is not running')
return
}
submittedWork = true