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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user