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:
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
@@ -53,8 +53,8 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
type: z.literal('session/queue'),
|
||||
sessionId: sessionIdSchema,
|
||||
items: z.array(z.object({
|
||||
id: inboxItemIdSchema,
|
||||
placement: z.union([z.literal('queued'), z.literal('steering')]),
|
||||
id: messageIdSchema,
|
||||
placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]),
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
@@ -34,10 +34,10 @@ export type ToolEventView =
|
||||
|
||||
/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedInboxItem {
|
||||
/** Agent-owned occurrence identity; queue mutations address only `queued` items. */
|
||||
id: InboxItemId
|
||||
/** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */
|
||||
placement: 'queued' | 'steering'
|
||||
/** Message identity used by inbox mutations. */
|
||||
id: MessageId
|
||||
/** Agent-resolved FIFO placement; queued and steering items render on different surfaces, context items stay invisible until claimed. */
|
||||
placement: 'queued' | 'steering' | 'context'
|
||||
/** Complete pending message; it is not durable until the Agent claims it. */
|
||||
message: Message
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ export type {
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import type { z as zCore } from 'zod'
|
||||
type ZodIssue = zCore.core.$ZodIssue
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
|
||||
/**
|
||||
* Message correlation id: the initiator mints it on a request; a response
|
||||
@@ -45,8 +45,8 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-busy': { reason: string }
|
||||
'queue-item-not-found': { itemId: InboxItemId }
|
||||
'steer-unavailable': { itemId: InboxItemId }
|
||||
'queue-item-not-found': { itemId: MessageId }
|
||||
'steer-unavailable': { itemId: MessageId }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type {
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
|
||||
/** InboxItemId: one brand cast after non-empty string validation. */
|
||||
export const inboxItemIdSchema = z.string().min(1) as unknown as z.ZodType<InboxItemId>
|
||||
/** MessageId: one brand cast after non-empty string validation. */
|
||||
export const messageIdSchema = z.string().min(1) as unknown as z.ZodType<MessageId>
|
||||
|
||||
/**
|
||||
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
|
||||
@@ -264,7 +264,7 @@ export const sessionPromptValueSchema = z.object({
|
||||
/** session.updateQueue request payload. */
|
||||
export const sessionUpdateQueueRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
itemId: inboxItemIdSchema,
|
||||
itemId: messageIdSchema,
|
||||
action: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
|
||||
z.object({ kind: z.literal('remove') }),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* else references RequestPayload<'session.*'> / ResponseValue<'session.*'>.
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||
// cordis Context merge (via dsh-agent) must not enter client aggregates.
|
||||
@@ -287,7 +287,7 @@ export interface SessionsApi {
|
||||
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
|
||||
* Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
|
||||
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: MessageId; action: QueueAction }>):
|
||||
Promise<RpcResponse<{ accepted: true }>>
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user