Merge remote-tracking branch 'origin/stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring

# Conflicts:
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
This commit is contained in:
Yichen Jiang
2026-08-09 20:41:55 +08:00
1623 changed files with 15506 additions and 5450 deletions

View File

@@ -7,8 +7,9 @@ import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { dirname, 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 { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
@@ -89,14 +90,6 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-
/** 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
@@ -129,7 +122,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.
@@ -165,7 +158,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.
@@ -391,14 +384,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
@@ -407,7 +400,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. */
@@ -850,17 +843,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>()
/**
* Serializes `agentPreset.select` per session. Two concurrent selects both
* pass the blank check, and the second `unmountPresetFor` then finds nothing
@@ -878,29 +871,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/**
* 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,
@@ -909,21 +901,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)
}
/**
@@ -969,7 +961,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (presets === undefined) {
return {
setup: (agentCtx: Context) => {
installTarget(agentCtx)
installSelection(agentCtx)
return Promise.resolve()
},
}
@@ -978,7 +970,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return {
agentPreset: resolvedId,
setup: async (agentCtx: Context) => {
installTarget(agentCtx)
installSelection(agentCtx)
await presets.mount(agentCtx, resolvedId)
},
}
@@ -1014,7 +1006,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) => {
@@ -1493,10 +1485,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.
*/
@@ -1507,7 +1499,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.
@@ -1520,13 +1512,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 },
}),
}
}
@@ -1592,7 +1584,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),
@@ -1779,9 +1771,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 (
@@ -1928,7 +1921,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 })
@@ -1946,20 +1939,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
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
// A switch is also how this deployment's default is chosen: the next
// session created without one of its own starts here. Sessions that
// have already logged a route are unaffected — they derive from
// their own log (see targetFor).
// have already logged a selection are unaffected — they derive from
// their own log (see selectionFor).
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)}`,
@@ -2349,7 +2342,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
@@ -2486,7 +2479,7 @@ 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()
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
@@ -2494,8 +2487,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,
}))
},
@@ -3179,11 +3172,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' }))
}
}),