Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ config.local.yaml
data/
.cache/
.neocode/projects/**/.transcripts/
.gocache/
.gomodcache/

# Editor/IDE
.idea/
Expand All @@ -43,6 +45,7 @@ workspace.xml
.claude/
.windsurf/
.codebuddy/
.agents/
# VitePress / frontend build artifacts
www/.vitepress/cache/
www/.vitepress/dist/
Expand Down
17 changes: 11 additions & 6 deletions docs/gateway-rpc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,12 @@ type RunParams struct {
}
```

- 多模态图片约束
- 多模态附件约束
- `type=image` 时 `media.mime_type` 必填。
- `media.uri` 与 `media.asset_id` 必须二选一,不能同时为空或同时提供。
- `media.uri` 仅用于后端可读取的本地路径;Web 浏览器上传图片应先通过 `POST /api/session-assets` 保存,再在 `gateway.run` 中使用 `media.asset_id` 引用。
- `media.uri` 仅用于后端可读取的本地路径;Web 浏览器上传图片或文本应先通过 `POST /api/session-assets` 保存,再在 `gateway.run` 中使用 `media.asset_id` 引用。
- `asset_id` 必须属于当前 `session_id`,不存在或跨 session 引用会在 runtime 输入准备阶段失败。
- 文本附件(如 `.md`、`.json`、`.csv`)使用 `type=image` + 真实文本 mime 表达,runtime 端按 `session.TextAssetWhitelist` 自动判定并内联为 user message 的 text part;无需新增 `type` 字段。详见 issue #701。

- Response Schema:
- Success(受理即返回):
Expand Down Expand Up @@ -242,13 +243,15 @@ type RunParams struct {
- Content-Type: `multipart/form-data`
- Fields:
- `session_id`: 目标会话 ID,必填。
- `file`: 图片文件,必填。
- `file`: 图片或文本文件,必填。
- Server-side validation:
- 仅接受 `image/png`、`image/jpeg`、`image/webp`。
- 接受 `image/png`、`image/jpeg`、`image/webp`(按文件头嗅探)。
- 同时接受会话侧文本资产白名单内的扩展名(`.txt`、`.md`、`.json`、`.yaml`、`.yml`、`.csv`)与对应 MIME(`text/plain`、`text/markdown`、`application/json`、`text/yaml`、`application/x-yaml`、`text/csv`)。
- 文本资产额外校验 UTF-8,非 UTF-8 内容返回 `415`。
- MIME 以服务端文件头检测结果为准,不信任浏览器声明。
- 空文件返回 `400`。
- 超过 `MaxSessionAssetBytes` 返回 `413`。
- 非图片或不支持类型返回 `415`。
- 超过 `MaxSessionAssetBytes`(图片)或 `MaxTextAssetBytes`(文本)返回 `413`。
- 不在任一白名单内的类型返回 `415`。
- 未认证返回 `401`,Origin/CORS 或 ACL 拒绝返回 `403`。
- 工作区不存在返回 `404 workspace not found`;目标 session 不在该工作区返回 `404 session not found`。
- Response:
Expand All @@ -262,6 +265,8 @@ type RunParams struct {
}
```

文本附件上传成功后,runtime 会在 `PrepareUserInput` 阶段按 `session.TextAssetWhitelist` 命中后自动读取并内联为 user message 的 `text` content part(带文件名边界 + 可选截断提示),Provider 层不感知"文件"概念。配置项 `runtime.assets.text_asset_enabled`(默认 `true`)可关闭该行为,关闭后文本附件会作为普通附件原样提交。详见 `docs/runtime-provider-event-flow.md` 与 issue #701。

### GET /api/session-assets/{session_id}/{asset_id}

- Auth Required: Yes(`Authorization: Bearer <token>`)
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ runtime:
assets:
max_session_asset_bytes: 20971520
max_session_assets_total_bytes: 20971520
text_asset_enabled: true
max_text_asset_bytes: 262144 # 256 KiB
max_text_asset_chars: 250000 # ~25 万 UTF-8 字符

tools:
webfetch:
Expand Down Expand Up @@ -112,6 +115,9 @@ context:
| `runtime.hooks.items` | user hooks 列表;支持 `builtin/sync` 与 `http/observe` 两种子类型 |
| `runtime.assets.max_session_asset_bytes` | 单个 `session_asset` 最大原始字节数,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 |
| `runtime.assets.max_session_assets_total_bytes` | 单次请求可携带的 `session_asset` 原始总字节上限,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 |
| `runtime.assets.text_asset_enabled` | 是否把文本类 asset 在提交会话前内联为 text part,默认 `true`;关闭后文本 asset 走原图片路径(回滚开关) |
| `runtime.assets.max_text_asset_bytes` | 单个文本 asset 最大字节数,默认 `262144`(256 KiB),硬上限 `4 MiB`;超过保存时返回 413 |
| `runtime.assets.max_text_asset_chars` | 单个文本 asset 在 UTF-8 解码后的最大字符数,默认 `250000`,硬上限 `4_000_000`;超过时截断并保留截断提示 |

### `runtime.hooks.items` 字段约束

Expand Down
65 changes: 64 additions & 1 deletion internal/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ type RuntimeConfig struct {
type RuntimeAssetsConfig struct {
MaxSessionAssetBytes int64 `yaml:"max_session_asset_bytes,omitempty"`
MaxSessionAssetsTotalBytes int64 `yaml:"max_session_assets_total_bytes,omitempty"`
// TextAssetEnabled 控制是否把文本类 asset 在提交会话前内联为 text part;关闭时文本 asset
// 仅作为图像风格的会话附件存在(保持向后兼容,便于回滚)。
TextAssetEnabled *bool `yaml:"text_asset_enabled,omitempty"`
// MaxTextAssetBytes 限制单个文本 asset 的字节上限(保存与读取都受此约束)。
MaxTextAssetBytes int64 `yaml:"max_text_asset_bytes,omitempty"`
// MaxTextAssetChars 限制单个文本 asset 在 UTF-8 解码后允许保留的最大字符数。
MaxTextAssetChars int `yaml:"max_text_asset_chars,omitempty"`
}

// defaultRuntimeConfig 返回 runtime 配置的静态默认值。
Expand All @@ -40,9 +47,13 @@ func defaultRuntimeConfig() RuntimeConfig {

// defaultRuntimeAssetsConfig 返回 runtime 附件限制配置默认值。
func defaultRuntimeAssetsConfig() RuntimeAssetsConfig {
enabled := true
return RuntimeAssetsConfig{
MaxSessionAssetBytes: session.MaxSessionAssetBytes,
MaxSessionAssetsTotalBytes: provider.MaxSessionAssetsTotalBytes,
TextAssetEnabled: &enabled,
MaxTextAssetBytes: session.DefaultMaxTextAssetBytes,
MaxTextAssetChars: session.DefaultMaxTextAssetChars,
}
}

Expand Down Expand Up @@ -107,9 +118,24 @@ func (c RuntimeConfig) ResolveRequestAssetBudget() provider.RequestAssetBudget {
return c.Assets.ResolveRequestAssetBudget()
}

// ResolveTextAssetPolicy 归一化 runtime 文本附件策略并施加代码硬上限兜底。
func (c RuntimeConfig) ResolveTextAssetPolicy() session.TextAssetPolicy {
return c.Assets.ResolveTextAssetPolicy()
}

// IsTextAssetEnabled 返回当前 runtime 文本附件内联开关。
func (c RuntimeConfig) IsTextAssetEnabled() bool {
return c.Assets.IsTextAssetEnabled()
}

// Clone 复制附件限制配置,避免调用方共享可变状态。
func (c RuntimeAssetsConfig) Clone() RuntimeAssetsConfig {
return c
out := c
if c.TextAssetEnabled != nil {
enabled := *c.TextAssetEnabled
out.TextAssetEnabled = &enabled
}
return out
}

// ApplyDefaults 在配置缺失、为零或非法时回填附件限制默认值。
Expand All @@ -123,6 +149,20 @@ func (c *RuntimeAssetsConfig) ApplyDefaults(defaults RuntimeAssetsConfig) {
if c.MaxSessionAssetsTotalBytes <= 0 {
c.MaxSessionAssetsTotalBytes = defaults.MaxSessionAssetsTotalBytes
}
if c.TextAssetEnabled == nil {
// nil 视为 true,与 IsTextAssetEnabled() 的 nil-as-true 语义对齐。
enabled := true
if defaults.TextAssetEnabled != nil {
enabled = *defaults.TextAssetEnabled
}
c.TextAssetEnabled = &enabled
}
if c.MaxTextAssetBytes <= 0 {
c.MaxTextAssetBytes = defaults.MaxTextAssetBytes
}
if c.MaxTextAssetChars <= 0 {
c.MaxTextAssetChars = defaults.MaxTextAssetChars
}
}

// Validate 校验附件限制配置是否满足最小约束;0 表示使用默认值,仅禁止负数。
Expand All @@ -133,9 +173,23 @@ func (c RuntimeAssetsConfig) Validate() error {
if c.MaxSessionAssetsTotalBytes < 0 {
return errors.New("runtime.assets.max_session_assets_total_bytes must be greater than or equal to 0")
}
if c.MaxTextAssetBytes < 0 {
return errors.New("runtime.assets.max_text_asset_bytes must be greater than or equal to 0")
}
if c.MaxTextAssetChars < 0 {
return errors.New("runtime.assets.max_text_asset_chars must be greater than or equal to 0")
}
return nil
}

// IsTextAssetEnabled 返回文本类 asset 内联开关;nil 视为 true。
func (c RuntimeAssetsConfig) IsTextAssetEnabled() bool {
if c.TextAssetEnabled == nil {
return true
}
return *c.TextAssetEnabled
}

// ResolveSessionAssetPolicy 归一化附件存储策略并应用代码硬上限。
func (c RuntimeAssetsConfig) ResolveSessionAssetPolicy() session.AssetPolicy {
return session.NormalizeAssetPolicy(session.AssetPolicy{
Expand All @@ -150,3 +204,12 @@ func (c RuntimeAssetsConfig) ResolveRequestAssetBudget() provider.RequestAssetBu
MaxSessionAssetsTotalBytes: c.MaxSessionAssetsTotalBytes,
}, assetPolicy.MaxSessionAssetBytes)
}

// ResolveTextAssetPolicy 归一化文本附件策略并应用代码硬上限。
func (c RuntimeAssetsConfig) ResolveTextAssetPolicy() session.TextAssetPolicy {
return session.NormalizeTextAssetPolicy(session.TextAssetPolicy{
Whitelist: session.DefaultTextAssetWhitelist(),
MaxTextAssetBytes: c.MaxTextAssetBytes,
MaxTextAssetChars: c.MaxTextAssetChars,
})
}
18 changes: 18 additions & 0 deletions internal/config/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ func TestRuntimeConfigCloneAndDefaults(t *testing.T) {
}
}

// TestRuntimeAssetsConfigApplyDefaultsNilAsTrue 验证当 defaults.TextAssetEnabled 为 nil 时,
// ApplyDefaults 将 TextAssetEnabled 设为 true,与 IsTextAssetEnabled() 的 nil-as-true 语义对齐。
func TestRuntimeAssetsConfigApplyDefaultsNilAsTrue(t *testing.T) {
t.Parallel()

var zero RuntimeAssetsConfig
zero.ApplyDefaults(RuntimeAssetsConfig{})
if zero.TextAssetEnabled == nil {
t.Fatal("expected TextAssetEnabled to be non-nil after ApplyDefaults")
}
if !*zero.TextAssetEnabled {
t.Fatalf("expected TextAssetEnabled default true, got false")
}
if !zero.IsTextAssetEnabled() {
t.Fatalf("expected IsTextAssetEnabled() true, got false")
}
}

func TestRuntimeConfigValidate(t *testing.T) {
t.Parallel()

Expand Down
53 changes: 51 additions & 2 deletions internal/gateway/network_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"

"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/net/websocket"
Expand Down Expand Up @@ -435,14 +436,18 @@ func (s *NetworkServer) handleSessionAssetUpload(writer http.ResponseWriter, req
return
}

file, _, err := request.FormFile("file")
file, fileHeader, err := request.FormFile("file")
if err != nil {
writeJSONResponse(writer, http.StatusBadRequest, map[string]string{"error": "file is required"})
return
}
defer func() {
_ = file.Close()
}()
fileName := ""
if fileHeader != nil {
fileName = strings.TrimSpace(fileHeader.Filename)
}

payload, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil {
Expand All @@ -460,7 +465,11 @@ func (s *NetworkServer) handleSessionAssetUpload(writer http.ResponseWriter, req

mimeType := detectAllowedUploadImageMime(payload)
if mimeType == "" {
writeJSONResponse(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "unsupported image type"})
// 文本附件走白名单嗅探:先按声明/扩展名匹配,再做 UTF-8 校验。
mimeType = detectAllowedUploadTextMime(payload, fileName)
}
if mimeType == "" {
writeJSONResponse(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "unsupported asset type"})
return
}

Expand Down Expand Up @@ -626,6 +635,46 @@ func detectAllowedUploadImageMime(payload []byte) string {
}
}

// detectAllowedUploadTextMime 按会话侧文本资产白名单探测上传文件 MIME。
// 流程:先按文件扩展名查 mime;若未命中则尝试从 payload 内容头推断(仅对纯文本类文件)。
// 任一环节命中后必须再次校验 payload 是否为合法 UTF-8,非 UTF-8 返回空。
// 与 detectAllowedUploadImageMime 并列,互不冲突。
func detectAllowedUploadTextMime(payload []byte, fileName string) string {
if len(payload) == 0 {
return ""
}
whitelist := agentsession.DefaultTextAssetWhitelist()
if whitelist.IsEmpty() {
return ""
}
mimeType := whitelist.LookupByExtension(fileName)
if mimeType == "" {
// 用 http.DetectContentType 做粗略内容嗅探;命中 text/* 即认为是文本。
probe := payload
if len(probe) > 512 {
probe = probe[:512]
}
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(probe)))
if !strings.HasPrefix(detected, "text/") {
return ""
}
// http.DetectContentType 对纯文本会返回 "text/plain; charset=utf-8",
// 需剥离 "; charset=..." 参数后再与白名单对比,否则永远匹配失败。
mediaType := strings.TrimSpace(strings.SplitN(detected, ";", 2)[0])
// 仅接受白名单内的 mime,避免被任意 text/* 通过。
if !whitelist.LookupByMime(mediaType) {
return ""
}
mimeType = mediaType
}
// UTF-8 校验:文本资产进入 runtime 后会被读取并按 UTF-8 解码,非 UTF-8 会立刻失败。
// 提前在网关层拒绝,避免无效上传占用存储。
if !utf8.Valid(payload) {
return ""
}
return mimeType
}

// parseSessionAssetPath 从 /api/session-assets/{session_id}/{asset_id} 提取路径参数。
func parseSessionAssetPath(rawPath string) (string, string, bool) {
cleanPath := path.Clean("/" + strings.TrimSpace(rawPath))
Expand Down
Loading
Loading