mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
refactor(attachment): unify prompt content admission
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: effcf3fe21b8091ee0eb012dd80dfe2f4e75d394
|
||||
README.zh.md: 6f1a260f60377c6a8079ffbde757fe4dbada9337
|
||||
README.md: a75dbdbaae526348e40da90ae7a57b702cb8b264
|
||||
README.zh.md: 5411878a5a87dd2b02cd46fc37f5aa6ddea291ac
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
|
||||
|
||||
Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and validates every same-Agent receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
|
||||
Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
|
||||
历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,并在持久化图片前验证每个凭证属于同一个准确 Agent。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
|
||||
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
|
||||
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import {
|
||||
ReasoningEffortId, createUserMessage, freezeMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -328,12 +330,12 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
}
|
||||
const durable = await durablePromptContent(
|
||||
this.ctx,
|
||||
const admission = resolvePromptFileReceipts(
|
||||
request.content,
|
||||
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
|
||||
)
|
||||
const message: UserMessage = createUserMessage({ content: durable.content, source })
|
||||
const content = await this.ctx.attachments.admitPromptContent(admission.content)
|
||||
const message: UserMessage = createUserMessage({ content, source })
|
||||
if (this.ctx.agents.get(agent.id) !== agent) {
|
||||
throw new RemoteError(
|
||||
'session/not-found',
|
||||
@@ -341,7 +343,7 @@ export class SessionCommandController {
|
||||
{ sessionId: agent.id },
|
||||
)
|
||||
}
|
||||
using binding = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId)
|
||||
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
|
||||
if (request.mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
binding.commit()
|
||||
@@ -520,36 +522,25 @@ export class SessionCommandController {
|
||||
}
|
||||
}
|
||||
|
||||
async function durablePromptContent(
|
||||
ctx: Context,
|
||||
content: readonly SessionPromptRequest['content'][number][],
|
||||
function resolvePromptFileReceipts(
|
||||
content: SessionPromptRequest['content'],
|
||||
stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
|
||||
): Promise<{ readonly content: ContentBlock[]; readonly receiptIds: readonly FileUploadReceiptId[] }> {
|
||||
const files = new Map<FileUploadReceiptId, FileAttachmentRef>()
|
||||
for (const part of content) {
|
||||
if (part.type !== 'file' || files.has(part.receiptId)) continue
|
||||
const file = stagedFile(part.receiptId)
|
||||
if (file === undefined) {
|
||||
): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } {
|
||||
const receiptIds = new Set<FileUploadReceiptId>()
|
||||
const resolved = content.map((part): AttachmentAdmissionPart => {
|
||||
if (part.type !== 'file') return part
|
||||
const attachment = stagedFile(part.receiptId)
|
||||
if (attachment === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/attachment-invalid',
|
||||
'File was not uploaded for this session.',
|
||||
{ reason: 'FILE_NOT_STAGED' },
|
||||
)
|
||||
}
|
||||
files.set(part.receiptId, file)
|
||||
}
|
||||
type NonFilePart = Exclude<SessionPromptRequest['content'][number], { readonly type: 'file' }>
|
||||
const admitted = await ctx.attachments.admitPromptContent(
|
||||
content.filter((part): part is NonFilePart => part.type !== 'file'),
|
||||
)
|
||||
let next = 0
|
||||
const durable = content.map((part) => {
|
||||
if (part.type === 'file') {
|
||||
return { type: 'file' as const, attachment: files.get(part.receiptId) as FileAttachmentRef }
|
||||
}
|
||||
return admitted[next++] as ContentBlock
|
||||
receiptIds.add(part.receiptId)
|
||||
return { type: 'file', attachment }
|
||||
})
|
||||
return { content: durable, receiptIds: [...files.keys()] }
|
||||
return { content: resolved, receiptIds: [...receiptIds] }
|
||||
}
|
||||
|
||||
function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
|
||||
|
||||
@@ -126,8 +126,8 @@ describe('Session file uploads', () => {
|
||||
.resolves.toMatchObject({ status: 405 })
|
||||
})
|
||||
|
||||
it('stages one verbatim upload and cites it from a later prompt as a file block', async () => {
|
||||
const { ctx, controller, uploads, agent, followup, saveFile } = await uploadHarness()
|
||||
it('stages one verbatim upload and preserves its order with an admitted image', async () => {
|
||||
const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal)
|
||||
expect(saveFile).toHaveBeenCalledTimes(1)
|
||||
expect(receipt.file.name).toBe('notes.pdf')
|
||||
@@ -148,14 +148,24 @@ describe('Session file uploads', () => {
|
||||
expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({
|
||||
attachments: [{ type: 'file', attachment: receipt.file }],
|
||||
})
|
||||
const image: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
saveImages.mockResolvedValueOnce([image])
|
||||
await controller.prompt(promptRequest([
|
||||
{ type: 'file', receiptId: receipt.receiptId },
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
{ type: 'text', text: 'read it' },
|
||||
]))
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.content).toEqual([
|
||||
{ type: 'file', attachment: receipt.file },
|
||||
{ type: 'image', attachment: image },
|
||||
{ type: 'text', text: 'read it' },
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user