Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/subsystems/attachment.i18n.yaml
#	docs/subsystems/attachment.md
#	docs/subsystems/attachment.zh.md
#	docs/subsystems/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/runtime/package.json
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-09 23:33:35 +08:00
3271 changed files with 69578 additions and 24953 deletions

View File

@@ -7,8 +7,9 @@ import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
@@ -85,14 +86,6 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/**
* The settings namespace carrying the user's default route. Named for the
* gateway rather than for the package, because this key is what a person reads
* and writes in `settings.yaml`; the row id in a composition happens to match
* but does not determine it.
*/
export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
/** Non-model settings namespaces intentionally served to the Web client. */
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
@@ -225,7 +218,7 @@ function isAborted(signal: AbortSignal): boolean {
* backwards from the window tail. Replacement copies never entered the
* conversation a reader sees — they restate a shadowed range for the model
* alone — so they consume no quota; the page stays one contiguous raw range,
* which keeps a compaction's log-only provenance on the same page as its
* which keeps a compaction's log-only `compact/summary` record on the same page as its
* replacement. The cut is the starting seq of the oldest message group (chunks
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
* includes the in-progress partial.
@@ -261,7 +254,7 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
/**
* Build the provider/model catalog over every registered route. Shared by the
* session-scoped `session.models` and host-scoped `llm.models`. Catalog
* membership stays advisory: an unlisted session target remains valid for
* membership stays advisory: an unlisted session selection remains valid for
* provider dispatch, but is not injected back into the selector after its
* owning catalog stops advertising it. Per-provider failures ride `failures`
* without failing the sound groups; groups that advertise nothing are dropped.
@@ -454,14 +447,14 @@ function directoryError(error: unknown): RpcError {
return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
}
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
/** Resolved Agent model and project-directory defaults consumed by the API implementation. */
export interface ApiProxyDefaults {
/**
* The route a session starts from when its own log names none. Read on
* The model selection a session starts from when its own log names none. Read on
* every access rather than captured, so a default saved during this process
* reaches the sessions that have not run a turn yet.
*/
defaultTarget: () => AgentLlmTarget
defaultModelSelection: () => ModelSelection
/**
* Record a selection as the new default. Either absent, or a closure that
* may itself decline — the gateway plugin always passes one, and it no-ops
@@ -470,7 +463,7 @@ export interface ApiProxyDefaults {
* reported and swallowed: the switch already applies to its own session,
* and undoing it because storage failed would be the worse outcome.
*/
persistDefaultTarget?: (target: AgentLlmTarget) => Promise<void>
saveDefaultModelSelection?: (selection: ModelSelection) => Promise<void>
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
/** Parent directory for name-created workspaces. */
@@ -843,17 +836,17 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
/**
* Implement ApiProxy over a composed host context.
* @param ctx - a context with the Host spine and Workspace registry mounted.
* @param defaults - host routing and project-directory defaults.
* @param defaults - Agent model and project-directory defaults.
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
/** The seed route each create/resume declares; re-read so it never goes stale. */
/** The seed model each create/resume declares; re-read so it never goes stale. */
const agentOptions = (): AgentOptions => {
const { provider, model } = defaults.defaultTarget()
const { provider, model } = defaults.defaultModelSelection()
return { provider, model }
}
type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
const targets = new WeakMap<Agent, WebLlmTargetRef>()
type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection }
const selections = new WeakMap<Agent, WebModelSelectionRef>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership and explicit title checks with Workspace mutations. */
@@ -871,29 +864,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
/**
* Install or return the session-local target that prompt assembly snapshots.
* Install or return the session-local model selection that prompt assembly snapshots.
*
* Precedence, resolved on EVERY read rather than seeded once: a selection
* made in this process, else the session's own latest logged request/header,
* else the live host default. Re-reading is what keeps the two tiers honest
* in both directions — a session that has run a turn derives its route from
* its log forever after, so changing the default never retargets it; and a
* session still blank (New Session reuses one rather than minting another)
* starts from a default saved after it was created. There is no create-time
* else the live Agent default. Re-reading keeps the two tiers exact in both
* directions: a session with a recorded request derives its selection from
* its log, while a blank session (New Session reuses one rather than minting
* another) reads any default saved after it was created. There is no create-time
* per-session override tier on this wire — if one returns (a create-options
* contribution), it must fold in between the selection and the log.
*/
function targetFor(agent: Agent): WebLlmTargetRef {
const installed = targets.get(agent)
function selectionFor(agent: Agent): WebModelSelectionRef {
const installed = selections.get(agent)
if (installed !== undefined) return installed
let picked: AgentLlmTarget | undefined
const target: WebLlmTargetRef = {
get current(): AgentLlmTarget {
let picked: ModelSelection | undefined
const selection: WebModelSelectionRef = {
get current(): ModelSelection {
if (picked !== undefined) return picked
// Incrementally folded by the session, so a per-step read costs
// O(new events) rather than a rescan.
const logged = agent.session.requestHeader()?.config
if (logged === undefined) return defaults.defaultTarget()
if (logged === undefined) return defaults.defaultModelSelection()
return {
provider: logged.provider,
model: logged.model,
@@ -902,21 +894,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
: { reasoningEffort: logged.reasoningEffort },
}
},
set current(next: AgentLlmTarget) {
set current(next: ModelSelection) {
picked = next
},
assembled: undefined,
}
installAgentLlmTarget(agent.ctx, target)
targets.set(agent, target)
return target
installModelSelection(agent.ctx, selection)
selections.set(agent, selection)
return selection
}
/** Pre-publication setup used by both fresh and resumed Web agents. */
function installTarget(agentCtx: Context): void {
function installSelection(agentCtx: Context): void {
const agent = agentCtx.agent
if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
targetFor(agent)
selectionFor(agent)
}
const hasSubagentOwner = (
@@ -927,7 +919,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
apiRemoteSubagentOwnershipError(sessionId)
const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> =>
inspectApiRemoteSession(ctx, sessionId)
const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installTarget })
const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installSelection })
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
@@ -936,7 +928,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the seam package holds no wire vocabulary); the
// mints the wire frame (the Service Definition package holds no wire vocabulary); the
// child activates only when a projection registry is composed, and the
// subscription unwinds with this gateway's fiber.
ctx.inject(['sessionProjections'], (projectionCtx) => {
@@ -1193,7 +1185,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return (await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: agentOptions(),
setup: installTarget,
setup: installSelection,
})).agent
}
@@ -1206,7 +1198,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
sessionId,
agentOptions: agentOptions(),
meta: { cwd },
setup: installTarget,
setup: installSelection,
})).agent
})().catch((error: unknown) => {
// Another Host entry path may have published the same identity while
@@ -1352,10 +1344,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
/**
* Whether an adapter currently serves this route, and therefore whether a
* session pointed at it can start a turn. Catalog membership cannot answer
* Whether an adapter currently serves this provider, and therefore whether
* a session selecting it can start a turn. Catalog membership cannot answer
* it: an adapter may serve a model its own catalog stopped advertising, so
* a route missing from the groups is not the same as one nothing serves.
* a provider missing from the groups is not the same as one nothing serves.
* A composition with no llm registry at all cannot judge and says yes —
* the dispatch it would have refused fails on its own terms.
*/
@@ -1366,7 +1358,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/**
* Resolve the addressed agent for a turn-starting method and refuse when no
* adapter serves its current route: a route nothing serves cannot start a
* adapter serves its current selection: a provider nothing serves cannot start a
* turn, and letting it try spends the whole pre-step path to fail inside
* the adapter with a message about registration. Refusing here names the
* model the session is pointed at while the draft is still in the composer.
@@ -1379,13 +1371,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return { refused: err(request, found.error) }
const agent = found.agent
const target = targetFor(agent).current
if (!routeServed(target.provider)) {
const selection = selectionFor(agent).current
if (!routeServed(selection.provider)) {
return {
refused: err(request, {
code: 'model-unavailable',
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
details: { provider: target.provider, model: target.model },
message: `no adapter serves provider "${selection.provider}"; select a model for this session`,
details: { provider: selection.provider, model: selection.model },
}),
}
}
@@ -1444,7 +1436,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
}
/** Map one redacted seam descriptor to its wire view. */
/** Map one redacted settings descriptor to its wire view. */
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
return {
ns: String(descriptor.ns),
@@ -1546,8 +1538,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
sessions: {
// Attached sessions summarize from memory; persisted-but-unattached (cold)
// sessions merge in from the persistence store so history survives restarts.
// Legacy logs without a cwd (pre-project stance) are not served — every
// session now records its project at create time.
// Logs without a cwd are not served; every session records its project
// at create time.
async list(request) {
return ok(request, { items: await listVisibleSessionSummaries() })
},
@@ -1631,9 +1623,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
)
}
// Host visibility is the authorization boundary. Consume the
// provider's globally ranked stream rather than binding every
// visible id into one SQLite statement, then re-check complete
// provenance before emitting any snippet.
// provider's globally ranked results rather than binding every
// visible id into one SQLite statement, then require each hit to
// name a visible session and a current message from that same
// session before emitting its snippet.
for (const hit of page.items) {
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
if (
@@ -1760,7 +1753,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const { sessionId } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const current = targetFor(found.agent).current
const current = selectionFor(found.agent).current
const { groups, failures } = await buildModelCatalog(ctx)
const routable = routeServed(current.provider)
return ok(request, { current: { ...current }, routable, groups, failures })
@@ -1791,16 +1784,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
}
const selected: AgentLlmTarget = {
const selected: ModelSelection = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
targetFor(found.agent).current = selected
selectionFor(found.agent).current = selected
try {
await defaults.persistDefaultTarget?.(selected)
await defaults.saveDefaultModelSelection?.(selected)
} catch (error: unknown) {
ctx.logger.warn(
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
@@ -1910,7 +1903,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
seedLength: cut,
},
agentOptions: agentOptions(),
setup: installTarget,
setup: installSelection,
})
} catch (error: unknown) {
return err(request, {
@@ -1947,7 +1940,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
try {
if (hasImage) {
const current = targetFor(agent).current
const current = selectionFor(agent).current
const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model)
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
return err(request, {
@@ -2222,6 +2215,31 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return subagentPromptError(request, error, signal)
}
},
// Deliberately no catalog, history, persistence, or parent Agent lookup:
// the core primitive alone authorizes the durable address against the
// live Activation, which is what keeps a live child interruptible while
// its parent Agent is offline. Absent targets are accepted no-ops there.
interrupt(request) {
const { parentSessionId, childSessionId } = request.payload
try {
ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
} catch (error: unknown) {
if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
return Promise.resolve(err(request, {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId },
}))
}
return Promise.resolve(err(request, {
code: 'internal',
message: 'subagent interrupt failed',
details: {},
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
workspace: {
@@ -2239,7 +2257,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// the Web picker collapsed onto the directory flow
// (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md).
// Delete it with the wire schema's `name` member, this
// `defaults.workspaceRoot`, the client seam that carried the name
// `defaults.workspaceRoot`, the client contract that carried the name
// (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm,
// `intentName`'s name branch, the manager's "name under workspaceRoot"
// contract), and the `dsh web --workspace-root` flag plus its apps/cli
@@ -2375,8 +2393,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
host: {
describe(request) {
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
const route = defaults.defaultTarget()
// TODO: version should read apps/cli's package.json; placeholder for now.
const selection = defaults.defaultModelSelection()
return Promise.resolve(ok(request, {
version: '0.0.1',
// Same source as session.create's fallback: the UI's default project
@@ -2384,8 +2402,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
cwd: defaults.cwd,
// Read live for the same reason: this is what the NEXT session will
// start from, so a saved default has to be what it reports.
provider: route.provider,
model: route.model,
provider: selection.provider,
model: selection.model,
attachedSessions: ctx.agents.list().length,
}))
},
@@ -2914,11 +2932,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// A provider's own settings carry its model catalog and endpoint,
// so a change there invalidates the model list even when the route
// set is untouched — `llm/adapters-updated` alone misses it. The
// gateway's own section is the other such source: it names the
// route every session with no logged one resolves to, so an
// Agent default section is the other such source: it names the
// selection every session with no logged one resolves to, so an
// externally edited default (another tab, a hand-edited
// settings.yaml) has to reach an open selector too.
if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) {
if (modelProviderNamespaces().has(name) || name === String(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)) {
queue.push(frame({ type: 'host/models-changed' }))
}
}),

View File

@@ -49,7 +49,7 @@ export interface EventsApi {
* attached session, then replays each session's still-pending approval/question requested
* frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
* generic projection pair (history-tail projections block + session/projection frames).
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
* since: resume hook, unimplemented in v1 (ignored if passed); reconnection = reopen the
* stream + refetch history.
*/
mux(request: RpcRequest<{ since?: Record<SessionId, number> }>, signal: AbortSignal): AsyncIterable<RpcRequest<MuxFrame>>

View File

@@ -37,13 +37,14 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
SessionSearchItem,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'

View File

@@ -3,8 +3,8 @@
* surfaces. `llm.providers` merges the configurable-provider directory
* (which providers CAN be configured, and where their settings live) with the
* live route registry; `llm.models` is the session-independent model catalog
* (the same groups as `session.models`, without the per-session current
* target). Both invalidate on the `host/models-changed` frame.
* (the same groups as `session.models`, without a per-session selection).
* Both invalidate on the `host/models-changed` frame.
*/
import type { RpcRequest, RpcResponse } from './rpc.ts'

View File

@@ -37,6 +37,7 @@ export interface RpcMethodMap {
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'subagent.interrupt': SubagentsApi['interrupt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']

View File

@@ -12,7 +12,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -141,12 +141,12 @@ export const sessionHistoryRequestSchema = z.object({
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
/** Complete provider/model target. */
export const modelTargetSchema = z.object({
/** Complete provider/model selection. */
export const modelSelectionSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<ModelTarget>>
}) satisfies z.ZodType<Wire<ModelSelection>>
/** One adapter-owned reasoning effort. */
export const modelReasoningEffortSchema = z.object({
@@ -225,7 +225,7 @@ export const sessionModelsRequestSchema = z.object({
/** session.models response value. */
export const sessionModelsValueSchema = z.object({
current: modelTargetSchema,
current: modelSelectionSchema,
routable: z.boolean(),
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
@@ -241,7 +241,7 @@ export const sessionSelectModelRequestSchema = z.object({
/** session.selectModel response value. */
export const sessionSelectModelValueSchema = z.object({
selected: modelTargetSchema,
selected: modelSelectionSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */

View File

@@ -59,8 +59,8 @@ export type PromptContentPart =
| { type: 'text'; text: string }
| { type: 'image'; mediaType: ImageMediaType; data: string; name?: string }
/** Complete model target selected for one session. */
export interface ModelTarget {
/** Complete model selection for one session. */
export interface ModelSelection {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
@@ -121,8 +121,8 @@ export interface ModelCatalogFailure {
/** Detached model-directory snapshot for one session. */
export interface SessionModels {
/** Target selected for the session's next assembled step. */
current: ModelTarget
/** Model selection for the session's next assembled step. */
current: ModelSelection
/**
* Whether an adapter currently serves `current.provider`, and therefore
* whether this session can start a turn at all. Deliberately NOT derivable
@@ -222,7 +222,7 @@ export interface SessionsApi {
* Reads a window of history events; page boundaries align to append-origin message
* boundaries: one page = all raw events owned by a whole number of such messages (including
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
* `maxMessages`, so a compaction's `compact/summary` record stays on the page of its replacement. The tail
* page (beforeSeq absent) additionally carries the in-flight
* partial — chunk events already emitted for the last unfinalized message.
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
@@ -246,7 +246,7 @@ export interface SessionsApi {
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete target for this session. Exact model metadata
* Selects the complete model selection for this session. Exact model metadata
* validates an optional reasoning effort, while catalog membership remains
* advisory. Session-backed subagents reject with `agent-busy`.
*/
@@ -256,7 +256,7 @@ export interface SessionsApi {
model: string
reasoningEffort?: string
}>):
Promise<RpcResponse<{ selected: ModelTarget }>>
Promise<RpcResponse<{ selected: ModelSelection }>>
/**
* Renames a session: appends a `session/title` event with the `user`

View File

@@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
/** subagent.interrupt request payload. */
export const subagentInterruptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
/** subagent.interrupt response value. */
export const subagentInterruptValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */

View File

@@ -40,6 +40,11 @@ export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Uniform acknowledgement that one interrupt request was admitted. */
export interface SubagentInterruptReceipt {
accepted: true
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
@@ -94,4 +99,17 @@ export interface SubagentsApi {
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
/**
* Interrupts a live continuable child's current turn under the address's
* durable direct-parent authority, without requiring a live parent Agent,
* consulting the catalog, or resuming anything. Fire-and-return: `accepted`
* acknowledges the admitted cancel signal, not target quiescence, so the
* child may remain visibly running briefly. Unclaimed queued follow-ups are
* kept and parked; an absent, idle, or already-completed target is likewise
* `accepted`.
*/
interrupt(
request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
): Promise<RpcResponse<SubagentInterruptReceipt>>
}

View File

@@ -59,6 +59,7 @@ import {
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentInterruptValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
@@ -98,6 +99,7 @@ export interface IApiClient {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
@@ -174,6 +176,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'subagent.interrupt': subagentInterruptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -411,6 +414,7 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
}
readonly host: IApiClient['host'] = {

View File

@@ -61,6 +61,7 @@ import {
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentInterruptRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
@@ -97,6 +98,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },

View File

@@ -7,28 +7,24 @@
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — physical carriers wrap `ctx.apiProxy` themselves.
*
* The gateway also owns the `api-gateway` settings section: the route a
* session starts from when its own log names none. The composition entry is
* the shipped default and the section layers the user's choice over it, so
* switching models in a conversation is what sets the default for the next
* one. Sessions that have already logged a route are never retargeted by it.
* The gateway consumes `ctx.agentDefaultModel`, the transport-independent default
* shared with direct front doors. Switching models persists through that
* service; sessions that have already logged a selection remain unchanged.
*/
import { resolve } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type { ApiProxy } from './api/index.ts'
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
import { createApiProxy } from './api-proxy.ts'
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'
export { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module 'cordis' {
@@ -38,62 +34,12 @@ declare module 'cordis' {
}
}
/**
* The `api-gateway` settings section: the route a session starts from when its
* own log names none. `workspaceRoot` is deliberately not part of it — that is
* a launcher fact, not a preference.
*/
export interface DefaultRouteSettings {
/** Default provider route for created agents. */
provider: string
/** Default model id. */
model: string
/** Default reasoning effort; absence preserves the adapter/provider default. */
reasoningEffort?: string
}
/**
* Gateway plugin config: host-level agent routing and Workspace creation root.
*
* `reasoningEffort` is deliberately absent, so the section carries one field
* the composition cannot. The seam resolves a section by MERGING the user
* layer over the composition entry per field, and an absent key cannot
* override a present one — so a composition-set effort would survive every
* later switch to a model that has none, and strand it for the next session
* to fail on. Effort is a per-model fact anyway: a deployment default belongs
* on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own),
* which resolves per model rather than per gateway.
*/
/** Gateway plugin config: the Host-only Workspace creation root. */
export interface Config {
/** Default provider route for created agents. */
provider: string
/** Default model id. */
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
}
/**
* Schema of the `api-gateway` section, exported because it IS that section's
* contract — the shape anything reading or writing `settings.yaml` addresses.
*/
export const DEFAULT_ROUTE_SCHEMA: z<DefaultRouteSettings> = z.object({
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
})
/** Project the stored/composed section onto the agent-facing target shape. */
function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
return {
provider: settings.provider,
model: settings.model,
...settings.reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(settings.reasoningEffort) },
}
}
/**
* The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
@@ -101,13 +47,11 @@ function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = [
'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
]
static Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
workspaceRoot: z.string(),
})
@@ -127,30 +71,9 @@ export class ApiProxyService extends Service implements ApiProxy {
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const cwd = process.cwd()
// The composition entry is the shipped default; the settings section
// layers the user's own choice over it, and a deployment without a
// settings provider simply keeps the entry.
const entry: DefaultRouteSettings = { provider: config.provider, model: config.model }
let route: () => DefaultRouteSettings = () => entry
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
setSource: (current) => {
route = current
},
// Nothing registration-level derives from the default: every consumer
// reads it through the thunk at the moment it needs a route.
onChange: () => {},
})
const api = createApiProxy(ctx, {
defaultTarget: () => routeTarget(route()),
// Wholesale, never a merge: switching to a model with no reasoning
// effort must clear a stored one, and a merged patch would strand it
// for the next session to fail on. This clears it because the entry
// below the user layer carries no effort to re-inherit — the reason
// `Config` deliberately has no such field. The section holds no
// secrets, so there is nothing a replace can collaterally drop.
persistDefaultTarget: async (target) => {
await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target)
},
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
})
@@ -165,8 +88,8 @@ export class ApiProxyService extends Service implements ApiProxy {
this.credentials = api.credentials
this.llm = api.llm
this.events = api.events
// createApiProxy returns closures (no `this` capture); bind only satisfies
// the unbound-method lint without changing behavior.
// createApiProxy returns closures (no `this` capture), so the bind is
// behavior-neutral.
this.respond = api.respond.bind(api)
}
}

View File

@@ -157,7 +157,7 @@ async function openNativePathWithIntent(
* with the default browser when the path names a document a browser renders.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform, environment, and runner seam for deterministic tests.
* @param internals - Platform, environment, and runner hooks for deterministic tests.
*/
export function openNativePath(
path: string,
@@ -172,7 +172,7 @@ export function openNativePath(
* so a YAML association with a browser cannot consume the gesture.
* @param path - absolute or host-resolvable text-document path.
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
* @param internals - Platform and runner hooks for deterministic tests.
*/
export function openNativeTextFile(
path: string,