Merge branch 'claude/web-llm-pi-ai-config-385e24' into claude/pi-ai-model-discovery

# Conflicts:
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/event-producer-consumer.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/llm/llm/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-06 10:50:20 +08:00
822 changed files with 18341 additions and 14753 deletions

View File

@@ -8,14 +8,12 @@ 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, AgentStatus, InboxItem, InboxItemId,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
@@ -30,7 +28,7 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
@@ -85,7 +83,7 @@ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
const COLD_SUMMARY_BATCH_SIZE = 16
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
/** Product settings intentionally exposed beside model-provider namespaces. */
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
@@ -762,128 +760,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
})
/**
* Per-session pending-occurrence mirror serving live and mux-open
* `session/queue` snapshots. It carries both queued and steering placements.
* Each terminal inbox event retires one matching occurrence, so repeated
* sends of the same identified message remain visible until every occurrence
* is claimed.
*/
const queuedMirror = new Map<SessionId, InboxItem[]>()
type UnseenQueueEvent =
| { readonly kind: 'update'; readonly item: InboxItem }
| { readonly kind: 'terminal' }
const unseenQueueEvents = new Map<SessionId, Map<InboxItemId, UnseenQueueEvent>>()
const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => {
let events = unseenQueueEvents.get(sessionId)
if (events === undefined) {
events = new Map()
unseenQueueEvents.set(sessionId, events)
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
const queueItems = (
agent: Agent,
splice?: SessionEventMap['agent/inbox/spliced'],
): QueuedInboxItem[] => {
const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
return splice?.target === target
? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
: messages
}
events.set(itemId, event)
// Only synchronous re-entrancy may deliver a mutation before its outer
// enqueue observer. Drop unmatched protocol-invalid observations instead
// of retaining process-local ids indefinitely.
queueMicrotask(() => {
const current = unseenQueueEvents.get(sessionId)
if (current?.get(itemId) !== event) return
current.delete(itemId)
if (current.size === 0) unseenQueueEvents.delete(sessionId)
})
}
const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => {
const events = unseenQueueEvents.get(sessionId)
const event = events?.get(itemId)
if (event === undefined) return undefined
events?.delete(itemId)
if (events?.size === 0) unseenQueueEvents.delete(sessionId)
return event
}
const publishQueue = (sessionId: SessionId): void => {
const items = queuedMirror.get(sessionId) ?? []
broadcast({
type: 'session/queue',
sessionId,
items: items.map(item => ({
id: item.id,
placement: item.placement,
message: item.message,
return [
...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })),
...project('next-step').map(message => ({
id: message.id,
// Only user-origin messages are steering; injected context (approval
// notices, task completion, attached snapshots) is not a user action
// and must not render as a pending steering bubble.
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
message,
})),
})
}
ctx.effect(() => {
const retireKnown = (sessionId: SessionId, itemId: InboxItemId): boolean => {
const entries = queuedMirror.get(sessionId)
if (entries === undefined) return false
const index = entries.findIndex(entry => entry.id === itemId)
if (index === -1) return false
entries.splice(index, 1)
if (entries.length === 0) queuedMirror.delete(sessionId)
return true
}
const retire = (agent: Agent, item: InboxItem): boolean => {
if (retireKnown(agent.id, item.id)) return true
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
return false
}
const disposers = [
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
const unseen = takeUnseen(agent.id, item.id)
if (unseen?.kind === 'terminal') return
let entries = queuedMirror.get(agent.id)
if (entries === undefined) {
entries = []
queuedMirror.set(agent.id, entries)
}
entries.push(unseen?.kind === 'update' ? unseen.item : item)
publishQueue(agent.id)
}),
ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) {
rememberUnseen(agent.id, item.id, { kind: 'update', item })
return
}
const index = entries.findIndex(entry => entry.id === item.id)
if (index === -1) {
rememberUnseen(agent.id, item.id, { kind: 'update', item })
return
}
entries.splice(index, 1, item)
publishQueue(agent.id)
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
if (item.placement === 'steering') {
// AgentLoop appends the durable steering/message synchronously after
// this claim. Retain and retire the mirror row in the following
// microtask so any re-entrant snapshot and the Host's linear mux
// stream keep it visible until the durable event exists.
const present = queuedMirror.get(agent.id)?.some(entry => entry.id === item.id) === true
if (!present) {
retire(agent, item)
return
}
queueMicrotask(() => {
if (retireKnown(agent.id, item.id)) publishQueue(agent.id)
})
} else if (retire(agent, item)) {
// Queued claims have no durable same-message handoff to order.
// Publish retirement synchronously as before.
publishQueue(agent.id)
}
}),
ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => {
let changed = false
for (const item of items) changed = retire(agent, item) || changed
if (changed) publishQueue(agent.id)
}),
ctx.on('session/disposed', (session: Session) => {
queuedMirror.delete(session.id)
unseenQueueEvents.delete(session.id)
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'api-proxy: queued mirror')
}
ctx.on('session/event', (session, event) => {
if (event.type !== 'agent/inbox/spliced') return
const agent = ctx.agents.get(session.id)
if (agent?.session !== session) return
broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) })
})
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
@@ -1583,7 +1489,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
page = await sessionQuery.searchSessions({
query: request.payload.query,
eventFilters: [
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
{ kind: 'type', values: ['user/message', 'assistant/message'] },
{ kind: 'surface', values: ['current'] },
],
limit: requestedPageLimit,
@@ -1935,21 +1841,33 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { itemId },
}))
}
const result = agent.updateInbox(itemId, action)
if (result === 'not-found') {
const target = agent.inbox.nextTurn.some(message => message.id === itemId)
? 'next-turn'
: agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
const message = target === undefined
? undefined
: (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
.find(candidate => candidate.id === itemId)
if (target === undefined || message === undefined) {
return Promise.resolve(err(request, {
code: 'queue-item-not-found',
message: 'queued item is no longer pending',
details: { itemId },
}))
}
if (result === 'steer-unavailable') {
if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
return Promise.resolve(err(request, {
code: 'steer-unavailable',
message: 'current turn no longer accepts steering',
details: { itemId },
}))
}
if (action.kind === 'edit') {
agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content }))
} else {
agent.inbox.remove(itemId)
if (action.kind === 'steer') agent.steer(message)
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
@@ -2636,16 +2554,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
// in arrival order per session; a reconnecting client rebuilds its
// queue view from these alone.
for (const [sessionId, items] of queuedMirror) {
queue.push(frame({
type: 'session/queue',
sessionId,
items: items.map(item => ({
id: item.id,
placement: item.placement,
message: item.message,
})),
}))
for (const session of ctx.sessions.list()) {
const agent = ctx.agents.get(session.id)
if (agent?.session === session && agent.inbox.hasPending) {
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
}
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream