Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters

# Conflicts:
#	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/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/convert.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/call-config.ts
#	packages/llm/llm/src/index.ts
#	packages/ui/acp-agent/src/index.ts
#	packages/ui/acp/tests/harness.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/src/index.ts
#	python/sdk/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-14 22:17:50 +08:00
672 changed files with 10295 additions and 14200 deletions

View File

@@ -1,30 +1,10 @@
/**
* The SDK-facing stdio JSON-RPC server plugin: mounting it wires a
* {@link JsonRpcLineTransport} over the process stdio and serves
* {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`,
* plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK
* client (e.g. the Python `deepseek_harness` package). The structured
* SDK-client analogue of the `acp` bridge: a client-driver plugin over
* `ctx.agents`, not a loop change and not a capability seam. Which process
* actually serves this protocol is a `cordis.yml` decision — the tree that
* loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such
* a tree for the single-exe distribution; see
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
*
* stdout is the protocol: this plugin must run in a tree that loads NO stdout
* logger (the console logger writes to stdout and would corrupt the JSON-RPC
* frames). The guarantee is config-only — see the package README.
*
* Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the
* `shutdown` request answers first, then the plugin disposes its own fiber and
* exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM,
* SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the
* whole root context.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
* export — the cordis Loader's `unwrapExports` does `exports.default ??
* exports`, so a stray default would collapse the module to the bare `apply`
* and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001).
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
* whether to load it; see the single-executable RFC and package README.
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
* This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin
* owns EOF and signal exits. Keep named plugin exports with no default export so
* Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
*
* @module @deepseek-ai/dsh-jsonrpc
*/
@@ -39,65 +19,29 @@ export * from './server.ts'
export * from './transport.ts'
export const name = 'jsonrpc'
// The server programs against the agent factory only: `agents` is read on
// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM
// seam is deliberately NOT injected — `initialize` reads it opportunistically
// via `ctx.get('llm')` (the topology-independent lookup for a non-injected
// service, per packages/AGENTS.md) to decide whether to lazily mount the
// DeepSeek adapter for the requested model.
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
export const inject = ['agents']
/**
* Plugin config. Every field is a runtime-only test seam — none is part of the
* schemastery {@link Config}, so nothing here is settable from a `cordis.yml`
* (production always serves the process stdio and exits via `process.exit`).
*/
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
export interface JsonRpcConfig {
/**
* Transport input override. Production omits this (the plugin reads
* `process.stdin`); tests inject an in-memory `Readable` to drive the server
* without a subprocess.
*/
/** Transport input override; production uses `process.stdin`. */
input?: Readable
/**
* Transport output override. Production omits this (the plugin writes
* `process.stdout` — the protocol channel); tests inject an in-memory
* `Writable` to capture frames.
*/
/** Transport output override; production uses `process.stdout`. */
output?: Writable
/**
* Process-exit override for the `shutdown` request path. Production omits
* this (`process.exit`); tests inject a recorder so a driven shutdown does
* not kill the test process.
*/
/** Process-exit override; production uses `process.exit`. */
exit?: (code: number) => void
}
export const Config: Schema<JsonRpcConfig> = Schema.object({})
/**
* Mount the SDK server on the process stdio: build the line transport and
* {@link HarnessSdkServer}, dispatch incoming requests, and start reading
* frames. Disposal is an effect: disposing this plugin's fiber runs
* `server.shutdown()` (disposes every SDK-created agent to quiescence and
* detaches the event subscriptions) and `transport.close()`.
*
* The `shutdown` request's process-exit semantics live HERE, because the
* plugin owns the server and transport: the request is answered first, an
* explicit output-write barrier confirms the response frame flushed, then the
* plugin disposes its
* OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the
* request's `server.shutdown()` already brought every SDK-created agent to
* quiescence (their session logs are flushed by the awaited agent-handle
* disposes), the fiber's effect disposer re-runs the idempotent shutdown and
* closes the transport, and the process exit that follows IS the teardown of
* the rest of the tree (the bin's EOF/signal handlers own root-context
* disposal for the process-level exits).
* Serve SDK requests over the configured streams. Effect disposal shuts down
* SDK-created agents and closes the transport. A `shutdown` response is flushed
* before this plugin's fiber is disposed and the process exits 0; the app bin
* owns root-context disposal for EOF and signals.
*/
export function apply(ctx: Context, config: JsonRpcConfig): void {
// Capture the fiber handle NOW, during apply(): the shutdown path runs LATER,
// from the transport's read loop, and must dispose exactly this plugin's
// fiber (cf. the injection-scope capture note in the acp bridge).
// The later transport callback must dispose this plugin's fiber, not its ambient context.
const fiber = ctx.fiber
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
const input = config.input ?? process.stdin
@@ -109,10 +53,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkServer(ctx, transport)
// The shutdown-request exit path, exactly once (a second `shutdown` frame
// racing the dispose shares the same task). Flush and disposal failures are
// settled independently: once shutdown was answered, process exit is still
// the honest outcome and neither failure may prevent the next teardown step.
// Share one exit task and attempt flush and disposal independently before exiting.
let exitTask: Promise<void> | undefined
const disposeAndExit = (): Promise<void> => {
exitTask ??= (async () => {
@@ -126,9 +67,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
transport.onRequest(async (method, params) => {
const result = await server.handleRequest(method, params)
if (method === 'shutdown') {
// The transport writes the returned result after this handler resolves.
// Schedule the explicit flush barrier after that write, then dispose this
// plugin's fiber and exit 0 (see apply's doc).
// Run after the handler result is written; the task then flushes, disposes, and exits.
setImmediate(() => { void disposeAndExit() })
}
return result

View File

@@ -1,13 +1,8 @@
/**
* `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin
* serves to out-of-process SDK clients (e.g. the Python `deepseek_harness`
* package). Requests: `initialize` → `session/prompt`* → `shutdown`.
* Notifications pushed to the host: `session.event` (every durable session
* event, verbatim), `session.finished` (per prompt turn settle),
* `subagent.started` / `subagent.finished` (child-session lineage and run
* outcomes). The server owns only the SDK-facing session map — the harness
* itself is the context the plugin mounts in; plugins, persistence, and
* the LLM adapter set all come from the external `cordis.yml`.
* JSON-RPC methods and notifications for SDK clients. Requests are
* `initialize`, repeated `session/prompt`, then `shutdown`; notifications carry
* durable session events, settled turns, and subagent lineage/outcomes. The
* external `cordis.yml` owns plugins, persistence, and the adapter set.
*
* @module @deepseek-ai/dsh-jsonrpc/server
*/
@@ -22,7 +17,7 @@ import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { JsonRpcTransportPeer } from './transport.ts'
/** Parameters of the `initialize` request (once per process, before any prompt). */
/** One-time SDK initialization parameters. */
export interface InitializeParams {
/** Working directory recorded on every SDK-created session's header. */
cwd: string
@@ -32,7 +27,7 @@ export interface InitializeParams {
model: string
}
/** Result of the `initialize` request: the server's identity for the SDK handshake. */
/** SDK handshake result. */
export interface InitializeResult {
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
serverInfo: { name: string; version: string }
@@ -49,7 +44,7 @@ export interface SessionPromptParams {
contentBlocks: ContentBlock[]
}
/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */
/** Accepted prompt result; the outcome is reported by `session.finished`. */
export interface SessionPromptResult {
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
accepted: true
@@ -67,11 +62,9 @@ interface SubagentRecord {
}
/**
* The SDK server over a booted harness context. Constructing it subscribes to
* the context's `session/event`, `session/created`, `agent/created`, and
* `subagent/end` events and forwards them to the host as notifications; the
* subscriptions live until {@link shutdown}. One instance serves one transport
* peer for the process lifetime — there is no re-`initialize`.
* SDK server over one booted harness context and transport peer. Construction
* subscribes to session, agent, and subagent lifecycle events until shutdown;
* reinitialization is unsupported.
*/
export class HarnessSdkServer {
private cwd = process.cwd()
@@ -104,8 +97,7 @@ export class HarnessSdkServer {
childSessionId: String(session.id),
})
}))
// Cache agent → session lineage on creation: by the time `subagent/end`
// fires the child agent may already be disposed and gone from the registry.
// Cache lineage before child disposal removes the agent from the registry.
this.disposers.push(ctx.on('agent/created', (agent) => {
this.subagentSessions.set(String(agent.id), {
childSessionId: String(agent.session.id),
@@ -135,10 +127,8 @@ export class HarnessSdkServer {
}
/**
* Handle `initialize`: record the SDK deployment facts and, when provider
* `deepseek` has no registered owner, mount the native DeepSeek adapter
* (credentials from `$DEEPSEEK_API_KEY`/`$DEEPSEEK_BASE_URL`). Other missing
* providers fail without guessing an implementation.
* 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.
*/
@@ -154,11 +144,9 @@ export class HarnessSdkServer {
}
/**
* Handle `session/prompt`: get-or-create the session's agent, send the
* content as the user message, await turn settle (quiescence), then notify
* `session.finished` with the settled turn's outcome. A session accepts at
* most one prompt at a time; an overlapping request fails immediately while
* other sessions remain independent.
* Get or create the session agent, send the prompt, await quiescence, then
* notify `session.finished`. A session accepts one prompt at a time; other
* sessions remain independent.
* @param params - the target session id and prompt content.
* @returns `{ accepted: true }` after the turn settled.
*/
@@ -183,10 +171,8 @@ export class HarnessSdkServer {
}
/**
* Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop
* quiescence), unmount the adapter fiber this server mounted (if any), and
* detach the event subscriptions. The CONTEXT stays up — the bin disposes it
* as part of process exit.
* Dispose SDK-created agents to quiescence, unmount the server-mounted adapter,
* and detach subscriptions. The surrounding context remains running.
* @returns an empty object (the JSON-RPC result).
*/
shutdown(): Promise<Record<string, never>> {
@@ -224,8 +210,8 @@ export class HarnessSdkServer {
}
/**
* Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a
* JSON-RPC error response) on an unknown method.
* Dispatch an incoming request; unknown methods throw for transport conversion
* to a JSON-RPC error response.
* @param method - the JSON-RPC method name.
* @param params - the raw params object from the wire.
* @returns the handler's result, to be serialized as the response.

View File

@@ -1,10 +1,7 @@
/**
* Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK
* server's stdio channel). One JSON frame per line; a frame with `id`+`method`
* is an incoming request, `id` alone matches a pending outgoing request, and
* `method` alone is a notification. Malformed lines are ignored (a resilient
* wire reader, not a validator); handler failures become JSON-RPC error
* responses, never a crashed transport.
* Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and
* `method` are requests, `id` alone is a response, and `method` alone is a
* notification. Malformed lines are ignored; handler failures become error frames.
*
* @module @deepseek-ai/dsh-jsonrpc/transport
*/
@@ -18,22 +15,18 @@ type RequestHandler = (method: string, params: Record<string, unknown>) => Promi
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
/**
* The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs
* to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s.
* Narrow on purpose so tests substitute a recording fake without a stream pair.
* Outbound request and notification surface used by {@link HarnessSdkServer}.
*/
export interface JsonRpcTransportPeer {
/**
* Send a request to the remote peer and await its response.
* Send a request and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
* @returns the remote peer's `result`; rejects on a JSON-RPC `error`
* response, a write failure, or transport/input closure.
* @returns the result; rejects on an error response, write failure, or closure.
*/
request(method: string, params: Record<string, unknown>): Promise<unknown>
/**
* Send a notification (no response expected). An omitted `params` sends no
* `params` member at all.
* Send a notification; omitted params produce no `params` member.
* @param method - the JSON-RPC method name.
* @param params - the optional notification parameters object.
*/
@@ -46,14 +39,10 @@ interface PendingRequest {
}
/**
* Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair.
* Inert until {@link start} attaches the input listeners; {@link close}
* detaches them and rejects every pending outgoing request (dispose-safe: the
* streams themselves are not destroyed — the caller owns them). Incoming
* requests are dispatched to the single {@link onRequest} handler (a missing
* handler answers `-32601 method not found`; a throwing handler answers
* `-32603` with the message); incoming notifications go to {@link
* onNotification} and are dropped without one.
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
* listeners; {@link close} detaches them and rejects pending requests without
* destroying the streams. Missing request handlers return `-32601`; handler
* failures return `-32603`. Notifications without a handler are dropped.
*/
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
private buffer = ''
@@ -78,8 +67,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Detach the input listeners and reject every pending outgoing request with
* "JSON-RPC transport closed". Safe to call without a prior {@link start}.
* Detach listeners and reject pending requests. Safe before {@link start}.
*/
close(): void {
this.input.off('data', this.onData)
@@ -89,7 +77,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Install THE handler for incoming requests (a later call replaces it).
* Install the request handler, replacing any prior handler.
* @param handler - resolves to the response `result`; a rejection becomes a
* `-32603` error response carrying the message.
*/
@@ -98,7 +86,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Install THE handler for incoming notifications (a later call replaces it).
* Install the notification handler, replacing any prior handler.
* @param handler - invoked per notification with the method and normalized
* params object.
*/
@@ -125,9 +113,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Wait until every frame written before this call has reached the output's
* write callback. The empty queued write is a barrier and emits no protocol
* bytes.
* Wait for prior frame write callbacks. The empty barrier emits no bytes.
* @returns a promise that settles with the output write callback.
*/
flush(): Promise<void> {
@@ -170,8 +156,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
try {
message = JSON.parse(line)
} catch {
// Swallows ONLY JSON.parse syntax errors: a malformed wire line is a
// peer bug this resilient reader skips; nothing else runs in the try.
// Only JSON syntax errors reach this catch; malformed peer lines are ignored.
return
}
if (!message || typeof message !== 'object') return