实现附件处理链路的统一引用化与可检索增强,避免大文件/多模态内容直接撑爆上下文并提升工具可用性。

本次补齐 text/image/video/archive 的标准化处理、会话级工具守卫、回放压缩、配置与文档对齐,并修复表格与流式输出相关体验问题。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 15:25:50 +08:00
parent 9d2900db02
commit 37a2ef35f4
27 changed files with 3227 additions and 278 deletions

View file

@ -16,3 +16,29 @@ This repository is fully consolidated under `oclaw/`.
- Use `oclaw` consistently in paths, symbols, and docs. - Use `oclaw` consistently in paths, symbols, and docs.
- Avoid introducing legacy aliases or old naming variants. - Avoid introducing legacy aliases or old naming variants.
## Attachment Replay Config
- Attachment-related limits are configured in `oclaw.json` under:
- `plugins.entries.memory-wiki.auto.attachments.tabular`
- Replay limits:
- `image_result_replay_cap_chars` (default `4000`, range `600..30000`)
- `video_result_replay_cap_chars` (default `4000`, range `600..30000`)
- Used to cap historical `query_image_attachment` / `query_video_attachment(task=transcript)` text replay in model context.
- Video transcript chunk defaults:
- `video_transcript_chunk_size` (default `1600`)
- `video_transcript_chunk_overlap` (default `200`)
- Unified archive budget defaults (zip/tar/tgz/gz):
- `archive_max_depth` (default `2`)
- `archive_max_file_count` (default `200`)
- `archive_max_entry_bytes` (default `10485760`)
- `archive_max_total_uncompressed_bytes` (default `52428800`)
- Archive parse errors now expose stable `error_code` values (for UI mapping and retries).
- Effective priority for replay-cap values:
- DB setting `AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS`
- DB setting `AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS`
- Environment variable `AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS`
- Environment variable `AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS`
- `oclaw.json` value
- Built-in default
See `docs/ENVIRONMENT_VARIABLES.md` for full runtime variable reference.

View file

@ -31,6 +31,15 @@
# #
# Oclaw runtime 默认不回退 legacy manager/runner;若紧急排障需临时回退,可显式打开: # Oclaw runtime 默认不回退 legacy manager/runner;若紧急排障需临时回退,可显式打开:
# AIA_OCLAW_ALLOW_LEGACY_FALLBACK=1 # AIA_OCLAW_ALLOW_LEGACY_FALLBACK=1
# 历史轮次里 query_image_attachment(OCR/描述)结果回放上限(字符数):
# AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS=4000
# 历史轮次里 query_video_attachment(task=transcript) 结果回放上限(字符数):
# AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS=4000
# 压缩包统一预算(zip/tar/tgz/gz)走 oclaw.json / Admin「附件」配置:
# archive_max_depth=2
# archive_max_file_count=200
# archive_max_entry_bytes=10485760
# archive_max_total_uncompressed_bytes=52428800
# #
# Oclaw 仍生效的工具循环预算(可在 Admin -> Tool Policy 配置): # Oclaw 仍生效的工具循环预算(可在 Admin -> Tool Policy 配置):
# AIA_TURN_MAX_TOOL_WORKERS=8 # AIA_TURN_MAX_TOOL_WORKERS=8

View file

@ -181,6 +181,34 @@
- 作用:`tool_log` 中 args/result 截断上限 - 作用:`tool_log` 中 args/result 截断上限
- 生效:`oclaw/platform/persistence/sqlite_store.py` - 生效:`oclaw/platform/persistence/sqlite_store.py`
- `AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS`
- 默认:`4000`
- 作用:限制历史轮次中 `query_image_attachment`(OCR/描述)结果回放到模型上下文时的 `text` 长度上限
- 范围:`600..30000`
- 优先级:DB setting(同名) > 环境变量 > `oclaw.json`(`plugins.entries.memory-wiki.auto.attachments.tabular.image_result_replay_cap_chars`) > 默认值
- 生效:`oclaw/runtime/direct_loop.py`
- `AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS`
- 默认:`4000`
- 作用:限制历史轮次中 `query_video_attachment`(`task=transcript`)结果回放到模型上下文时的 `text` 长度上限
- 范围:`600..30000`
- 优先级:DB setting(同名) > 环境变量 > `oclaw.json`(`plugins.entries.memory-wiki.auto.attachments.tabular.video_result_replay_cap_chars`) > 默认值
- 生效:`oclaw/runtime/direct_loop.py`
- `video_transcript_chunk_size` / `video_transcript_chunk_overlap`(`oclaw.json` 配置项)
- 默认:`1600` / `200`
- 作用:`query_video_attachment(task=transcript)` 落库转写文本时的默认分块参数(可被工具入参覆盖)
- 范围:`size: 1..8000`,`overlap: 1..4000`(实际使用会约束 overlap < size)
- 路径:`plugins.entries.memory-wiki.auto.attachments.tabular`
- 生效:`oclaw/runtime/tools/experts/generalist/video_query.py`
- `archive_max_depth` / `archive_max_file_count` / `archive_max_entry_bytes` / `archive_max_total_uncompressed_bytes`(`oclaw.json` 配置项)
- 默认:`2` / `200` / `10485760` / `52428800`
- 作用:统一 `archive_processor`(zip/tar/tgz/gz)安全预算:限制嵌套深度、文件数量、单文件解压大小、总解压大小
- 路径:`plugins.entries.memory-wiki.auto.attachments.tabular`
- 生效:`oclaw/platform/files/archive_processor.py`, `oclaw/platform/files/file_attachments.py`
- 错误码(工具/上下文可见):`archive_unsupported_format`, `archive_path_traversal`, `archive_max_depth_exceeded`, `archive_max_file_count_exceeded`, `archive_max_entry_bytes_exceeded`, `archive_max_total_uncompressed_bytes_exceeded`, `archive_link_entry_forbidden`, `archive_special_entry_forbidden`, `archive_parse_failed`
## MCP 与工具线侧 ## MCP 与工具线侧
- `AIA_MCP_SPECIALISTS` - `AIA_MCP_SPECIALISTS`

View file

@ -57,6 +57,48 @@
--- ---
## 2026-04-26 / Unreleased
### Added
- `AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS`
- 默认值:`4000`
- 用途:限制历史轮次中 `query_image_attachment`(OCR/描述)结果回放到模型上下文时的字符上限
- 影响模块:`oclaw/runtime/direct_loop.py`, `oclaw/interfaces/admin/chat_api.py`, `oclaw/interfaces/admin/static/app.js`
- `AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS`
- 默认值:`4000`
- 用途:限制历史轮次中 `query_video_attachment`(`task=transcript`)结果回放到模型上下文时的字符上限
- 影响模块:`oclaw/runtime/direct_loop.py`, `oclaw/interfaces/admin/chat_api.py`, `oclaw/interfaces/admin/static/app.js`
### Changed
- Admin「附件」设置页新增 `image_result_replay_cap_chars` 可视化配置,并写入 `oclaw.json`:
- 路径:`plugins.entries.memory-wiki.auto.attachments.tabular.image_result_replay_cap_chars`
- 范围:`600..30000`
- 是否需要重启:否(新 turn 读取时生效)
- Admin「附件」设置页新增视频相关配置,并写入 `oclaw.json`:
- `video_result_replay_cap_chars`(范围 `600..30000`)
- `video_transcript_chunk_size`(范围 `1..8000`)
- `video_transcript_chunk_overlap`(范围 `1..4000`)
- 路径:`plugins.entries.memory-wiki.auto.attachments.tabular`
- 是否需要重启:否(新 turn 读取时生效)
- Admin「附件」设置页新增压缩包统一预算配置,并写入 `oclaw.json`:
- `archive_max_depth`(默认 `2`)
- `archive_max_file_count`(默认 `200`)
- `archive_max_entry_bytes`(默认 `10485760`)
- `archive_max_total_uncompressed_bytes`(默认 `52428800`)
- 统一错误码:`archive_unsupported_format`, `archive_path_traversal`, `archive_max_depth_exceeded`, `archive_max_file_count_exceeded`, `archive_max_entry_bytes_exceeded`, `archive_max_total_uncompressed_bytes_exceeded`, `archive_link_entry_forbidden`, `archive_special_entry_forbidden`, `archive_parse_failed`
- 路径:`plugins.entries.memory-wiki.auto.attachments.tabular`
- 影响模块:`oclaw/platform/files/archive_processor.py`, `oclaw/platform/files/file_attachments.py`
- 是否需要重启:否(新 turn 读取时生效)
### Migration Checklist
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
- [x] 已更新 `README.md`
- [x] 已更新示例 env 文件
- [x] 已验证 Admin 配置页(如适用)
- [x] 已执行编译/测试回归
---
## 2026-04-21 / Unreleased ## 2026-04-21 / Unreleased
### Added ### Added

View file

@ -40,7 +40,7 @@ def _oclaw_config_path() -> Path:
p = Path(raw) p = Path(raw)
return p if p.is_absolute() else p.resolve() return p if p.is_absolute() else p.resolve()
# repo_root/src/admin/chat_api.py -> repo_root # repo_root/src/admin/chat_api.py -> repo_root
return Path(__file__).resolve().parents[2] / "oclaw" / "oclaw.json" return Path(__file__).resolve().parents[2] / "oclaw.json"
def _wiki_root_from_config() -> Path | None: def _wiki_root_from_config() -> Path | None:
@ -103,6 +103,15 @@ _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON" _SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
_CHAT_SPECIALIST_IDS: tuple[str, ...] = tuple(str(x) for x in SPECIALIST_IDS if str(x).strip()) _CHAT_SPECIALIST_IDS: tuple[str, ...] = tuple(str(x) for x in SPECIALIST_IDS if str(x).strip())
DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000 DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000
DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000
DEFAULT_TEXT_INLINE_MAX_CHARS = 12_000
DEFAULT_TEXT_CHUNK_SIZE = 1_600
DEFAULT_TEXT_CHUNK_OVERLAP = 200
DEFAULT_TEXT_QUERY_TOP_K = 5
DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS = 4_000
DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS = 4_000
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE = 1_600
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP = 200
def _safe_rel_avatar_name(name: str) -> str: def _safe_rel_avatar_name(name: str) -> str:
@ -441,9 +450,31 @@ def _tabular_limits_from_oclaw_config() -> dict[str, int]:
"max_excel_sheets": _safe_int(tabular.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500), "max_excel_sheets": _safe_int(tabular.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500),
"large_table_preview_rows": _safe_int(tabular.get("large_table_preview_rows"), 20, max_value=500), "large_table_preview_rows": _safe_int(tabular.get("large_table_preview_rows"), 20, max_value=500),
"tool_mode_enabled": bool(tabular.get("tool_mode_enabled", True)), "tool_mode_enabled": bool(tabular.get("tool_mode_enabled", True)),
"tool_mode_min_rows": _safe_int(tabular.get("tool_mode_min_rows"), 20_000), "tool_mode_min_rows": _safe_int(tabular.get("tool_mode_min_rows"), DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
"tool_mode_max_bytes": _safe_int(tabular.get("tool_mode_max_bytes"), 30 * 1024 * 1024), "tool_mode_max_bytes": _safe_int(tabular.get("tool_mode_max_bytes"), 30 * 1024 * 1024),
"sql_timeout_ms": _safe_timeout_ms(tabular.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS), "sql_timeout_ms": _safe_timeout_ms(tabular.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS),
"text_inline_max_chars": _safe_int(tabular.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000),
"text_chunk_size": _safe_int(tabular.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
"text_chunk_overlap": _safe_int(tabular.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000),
"text_query_top_k": _safe_int(tabular.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
"image_result_replay_cap_chars": _safe_int(
tabular.get("image_result_replay_cap_chars"), DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
),
"video_result_replay_cap_chars": _safe_int(
tabular.get("video_result_replay_cap_chars"), DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
),
"video_transcript_chunk_size": _safe_int(
tabular.get("video_transcript_chunk_size"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE, max_value=8_000
),
"video_transcript_chunk_overlap": _safe_int(
tabular.get("video_transcript_chunk_overlap"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP, max_value=4_000
),
"archive_max_depth": _safe_int(tabular.get("archive_max_depth"), 2, max_value=10),
"archive_max_file_count": _safe_int(tabular.get("archive_max_file_count"), 200, max_value=20_000),
"archive_max_entry_bytes": _safe_int(tabular.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000),
"archive_max_total_uncompressed_bytes": _safe_int(
tabular.get("archive_max_total_uncompressed_bytes"), 50 * 1024 * 1024, max_value=5_000_000_000
),
} }
@ -470,11 +501,69 @@ def _set_tabular_limits_into_oclaw_config(limits: dict[str, int]) -> dict[str, i
"max_excel_sheets": int(limits.get("max_excel_sheets") or DEFAULT_MAX_EXCEL_SHEETS), "max_excel_sheets": int(limits.get("max_excel_sheets") or DEFAULT_MAX_EXCEL_SHEETS),
"large_table_preview_rows": int(limits.get("large_table_preview_rows") or 20), "large_table_preview_rows": int(limits.get("large_table_preview_rows") or 20),
"tool_mode_enabled": bool(limits.get("tool_mode_enabled", True)), "tool_mode_enabled": bool(limits.get("tool_mode_enabled", True)),
"tool_mode_min_rows": int(limits.get("tool_mode_min_rows") or 20_000), "tool_mode_min_rows": int(limits.get("tool_mode_min_rows") or DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
"tool_mode_max_bytes": int(limits.get("tool_mode_max_bytes") or (30 * 1024 * 1024)), "tool_mode_max_bytes": int(limits.get("tool_mode_max_bytes") or (30 * 1024 * 1024)),
"sql_timeout_ms": int( "sql_timeout_ms": int(
_safe_timeout_ms(limits.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS) _safe_timeout_ms(limits.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS)
), ),
"text_inline_max_chars": int(
_safe_int(limits.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000)
),
"text_chunk_size": int(
_safe_int(limits.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000)
),
"text_chunk_overlap": int(
_safe_int(limits.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000)
),
"text_query_top_k": int(
_safe_int(limits.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50)
),
"image_result_replay_cap_chars": int(
_safe_int(
limits.get("image_result_replay_cap_chars"),
DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS,
min_value=600,
max_value=30_000,
)
),
"video_result_replay_cap_chars": int(
_safe_int(
limits.get("video_result_replay_cap_chars"),
DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS,
min_value=600,
max_value=30_000,
)
),
"video_transcript_chunk_size": int(
_safe_int(
limits.get("video_transcript_chunk_size"),
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE,
max_value=8_000,
)
),
"video_transcript_chunk_overlap": int(
_safe_int(
limits.get("video_transcript_chunk_overlap"),
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP,
max_value=4_000,
)
),
"archive_max_depth": int(_safe_int(limits.get("archive_max_depth"), 2, max_value=10)),
"archive_max_file_count": int(
_safe_int(limits.get("archive_max_file_count"), 200, max_value=20_000)
),
"archive_max_entry_bytes": int(
_safe_int(
limits.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000
)
),
"archive_max_total_uncompressed_bytes": int(
_safe_int(
limits.get("archive_max_total_uncompressed_bytes"),
50 * 1024 * 1024,
max_value=5_000_000_000,
)
),
} }
} }
} }
@ -493,6 +582,72 @@ def _chat_session_mode_setting_key(*, tenant_id: str, user_id: str, session_id:
return f"chat.session.mode.{tenant_id}.{user_id}.{session_id}.{field}" return f"chat.session.mode.{tenant_id}.{user_id}.{session_id}.{field}"
def _chat_user_mode_setting_key(*, tenant_id: str, user_id: str, field: str) -> str:
return f"chat.user.mode.{tenant_id}.{user_id}.{field}"
def _resolve_mode_settings(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
session_id: str,
) -> tuple[str, str, str]:
"""Resolve chat mode with global-user preference first, then session fallback."""
user_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode")
user_specialist_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist")
user_memory_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="memory_mode")
session_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
)
session_specialist_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
)
session_memory_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
)
mode_raw = str(store.get_setting(user_mode_key) or "").strip() or str(store.get_setting(session_mode_key) or "").strip()
specialist_raw = str(store.get_setting(user_specialist_key) or "").strip() or str(
store.get_setting(session_specialist_key) or ""
).strip()
memory_raw = str(store.get_setting(user_memory_mode_key) or "").strip() or str(store.get_setting(session_memory_mode_key) or "").strip()
interaction_mode = normalize_interaction_mode(mode_raw or "expert")
specialist = normalize_requested_specialist(specialist_raw or "generalist")
specialist = _apply_specialist_flags(store, specialist)
memory_mode = _normalize_memory_mode({"memory_mode": (memory_raw or "default")})
return interaction_mode, specialist, memory_mode
def _persist_mode_settings(
*,
store: SqliteStore,
tenant_id: str,
user_id: str,
session_id: str,
interaction_mode: str,
specialist: str,
memory_mode: str,
) -> None:
"""Persist as global user default and current-session compatibility snapshot."""
for key in (
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode"),
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"),
):
store.set_setting(key, interaction_mode)
for key in (
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist"),
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"),
):
store.set_setting(key, specialist)
for key in (
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="memory_mode"),
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"),
):
store.set_setting(key, memory_mode)
def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]]) -> None: def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]]) -> None:
chat = APIRouter(prefix="/admin/api/chat", tags=["chat"]) chat = APIRouter(prefix="/admin/api/chat", tags=["chat"])
@ -538,6 +693,21 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
user_id = str(ctx.get("user_id") or "") user_id = str(ctx.get("user_id") or "")
title = str(payload.get("title") or "").strip() or ("新会话" if _api_lang(store) == "zh" else "New Chat") title = str(payload.get("title") or "").strip() or ("新会话" if _api_lang(store) == "zh" else "New Chat")
s = store.create_session_for_user(title=title, tenant_id=tenant_id, user_id=user_id) s = store.create_session_for_user(title=title, tenant_id=tenant_id, user_id=user_id)
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(s.id),
)
_persist_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(s.id),
interaction_mode=interaction_mode,
specialist=specialist,
memory_mode=memory_mode,
)
return { return {
"ok": True, "ok": True,
"session": { "session": {
@ -603,6 +773,21 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
title=("新会话" if lang == "zh" else "New Chat"), tenant_id=tenant_id, user_id=user_id title=("新会话" if lang == "zh" else "New Chat"), tenant_id=tenant_id, user_id=user_id
) )
next_id = str(ns.id) next_id = str(ns.id)
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=next_id,
)
_persist_mode_settings(
store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=next_id,
interaction_mode=interaction_mode,
specialist=specialist,
memory_mode=memory_mode,
)
return {"ok": True, "next_session_id": next_id} return {"ok": True, "next_session_id": next_id}
@chat.post("/sessions/{session_id}/fork") @chat.post("/sessions/{session_id}/fork")
@ -793,19 +978,12 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
sess = _resolve_chat_session(store, ctx, session_id) sess = _resolve_chat_session(store, ctx, session_id)
if not sess: if not sess:
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
mode_key = _chat_session_mode_setting_key( interaction_mode, specialist, memory_mode = _resolve_mode_settings(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode" store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
) )
specialist_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
)
memory_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
)
interaction_mode = normalize_interaction_mode(store.get_setting(mode_key))
specialist = normalize_requested_specialist(store.get_setting(specialist_key))
specialist = _apply_specialist_flags(store, specialist)
memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)})
return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode} return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode}
@chat.post("/sessions/{session_id}/mode") @chat.post("/sessions/{session_id}/mode")
@ -825,19 +1003,16 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
interaction_mode = normalize_interaction_mode(payload.get("interaction_mode")) interaction_mode = normalize_interaction_mode(payload.get("interaction_mode"))
specialist = normalize_requested_specialist(payload.get("specialist")) specialist = normalize_requested_specialist(payload.get("specialist"))
specialist = _apply_specialist_flags(store, specialist) specialist = _apply_specialist_flags(store, specialist)
mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
)
specialist_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
)
memory_mode_key = _chat_session_mode_setting_key(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
)
memory_mode = _normalize_memory_mode(payload) memory_mode = _normalize_memory_mode(payload)
store.set_setting(mode_key, interaction_mode) _persist_mode_settings(
store.set_setting(specialist_key, specialist) store=store,
store.set_setting(memory_mode_key, memory_mode) tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
interaction_mode=interaction_mode,
specialist=specialist,
memory_mode=memory_mode,
)
return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode} return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode}
@chat.get("/admin/user-stats") @chat.get("/admin/user-stats")
@ -1066,9 +1241,21 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
"max_excel_sheets": DEFAULT_MAX_EXCEL_SHEETS, "max_excel_sheets": DEFAULT_MAX_EXCEL_SHEETS,
"large_table_preview_rows": 20, "large_table_preview_rows": 20,
"tool_mode_enabled": True, "tool_mode_enabled": True,
"tool_mode_min_rows": 20_000, "tool_mode_min_rows": DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS,
"tool_mode_max_bytes": 30 * 1024 * 1024, "tool_mode_max_bytes": 30 * 1024 * 1024,
"sql_timeout_ms": DEFAULT_TABULAR_SQL_TIMEOUT_MS, "sql_timeout_ms": DEFAULT_TABULAR_SQL_TIMEOUT_MS,
"text_inline_max_chars": DEFAULT_TEXT_INLINE_MAX_CHARS,
"text_chunk_size": DEFAULT_TEXT_CHUNK_SIZE,
"text_chunk_overlap": DEFAULT_TEXT_CHUNK_OVERLAP,
"text_query_top_k": DEFAULT_TEXT_QUERY_TOP_K,
"image_result_replay_cap_chars": DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS,
"video_result_replay_cap_chars": DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS,
"video_transcript_chunk_size": DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE,
"video_transcript_chunk_overlap": DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP,
"archive_max_depth": 2,
"archive_max_file_count": 200,
"archive_max_entry_bytes": 10 * 1024 * 1024,
"archive_max_total_uncompressed_bytes": 50 * 1024 * 1024,
} }
saved = _set_tabular_limits_into_oclaw_config(limits) saved = _set_tabular_limits_into_oclaw_config(limits)
return {"ok": True, "cleared": True, "limits": saved} return {"ok": True, "cleared": True, "limits": saved}
@ -1081,9 +1268,33 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
"max_excel_sheets": _safe_int(raw.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500), "max_excel_sheets": _safe_int(raw.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500),
"large_table_preview_rows": _safe_int(raw.get("large_table_preview_rows"), 20, max_value=500), "large_table_preview_rows": _safe_int(raw.get("large_table_preview_rows"), 20, max_value=500),
"tool_mode_enabled": bool(raw.get("tool_mode_enabled", True)), "tool_mode_enabled": bool(raw.get("tool_mode_enabled", True)),
"tool_mode_min_rows": _safe_int(raw.get("tool_mode_min_rows"), 20_000), "tool_mode_min_rows": _safe_int(raw.get("tool_mode_min_rows"), DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
"tool_mode_max_bytes": _safe_int(raw.get("tool_mode_max_bytes"), 30 * 1024 * 1024), "tool_mode_max_bytes": _safe_int(raw.get("tool_mode_max_bytes"), 30 * 1024 * 1024),
"sql_timeout_ms": _safe_timeout_ms(raw.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS), "sql_timeout_ms": _safe_timeout_ms(raw.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS),
"text_inline_max_chars": _safe_int(raw.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000),
"text_chunk_size": _safe_int(raw.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
"text_chunk_overlap": _safe_int(raw.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000),
"text_query_top_k": _safe_int(raw.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
"image_result_replay_cap_chars": _safe_int(
raw.get("image_result_replay_cap_chars"), DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
),
"video_result_replay_cap_chars": _safe_int(
raw.get("video_result_replay_cap_chars"), DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
),
"video_transcript_chunk_size": _safe_int(
raw.get("video_transcript_chunk_size"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE, max_value=8_000
),
"video_transcript_chunk_overlap": _safe_int(
raw.get("video_transcript_chunk_overlap"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP, max_value=4_000
),
"archive_max_depth": _safe_int(raw.get("archive_max_depth"), 2, max_value=10),
"archive_max_file_count": _safe_int(raw.get("archive_max_file_count"), 200, max_value=20_000),
"archive_max_entry_bytes": _safe_int(
raw.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000
),
"archive_max_total_uncompressed_bytes": _safe_int(
raw.get("archive_max_total_uncompressed_bytes"), 50 * 1024 * 1024, max_value=5_000_000_000
),
} }
saved = _set_tabular_limits_into_oclaw_config(next_limits) saved = _set_tabular_limits_into_oclaw_config(next_limits)
return {"ok": True, "limits": saved} return {"ok": True, "limits": saved}
@ -1253,20 +1464,26 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
interaction_mode, selected_specialist = _normalize_chat_mode(payload) interaction_mode, selected_specialist = _normalize_chat_mode(payload)
memory_mode = _normalize_memory_mode(payload) memory_mode = _normalize_memory_mode(payload)
if "interaction_mode" not in payload and "chat_mode" not in payload: if "interaction_mode" not in payload and "chat_mode" not in payload:
mode_key = _chat_session_mode_setting_key( interaction_mode, _, _ = _resolve_mode_settings(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode" store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
) )
interaction_mode = normalize_interaction_mode(store.get_setting(mode_key))
if "specialist" not in payload: if "specialist" not in payload:
specialist_key = _chat_session_mode_setting_key( _, selected_specialist, _ = _resolve_mode_settings(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist" store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
) )
selected_specialist = normalize_requested_specialist(store.get_setting(specialist_key))
if "memory_mode" not in payload: if "memory_mode" not in payload:
memory_mode_key = _chat_session_mode_setting_key( _, _, memory_mode = _resolve_mode_settings(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode" store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
) )
memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)})
selected_specialist = _apply_specialist_flags(store, selected_specialist) selected_specialist = _apply_specialist_flags(store, selected_specialist)
if not text_raw and not attachments: if not text_raw and not attachments:
raise HTTPException(status_code=400, detail="text_or_attachments_required") raise HTTPException(status_code=400, detail="text_or_attachments_required")
@ -1351,20 +1568,26 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
interaction_mode, selected_specialist = _normalize_chat_mode(payload) interaction_mode, selected_specialist = _normalize_chat_mode(payload)
memory_mode = _normalize_memory_mode(payload) memory_mode = _normalize_memory_mode(payload)
if "interaction_mode" not in payload and "chat_mode" not in payload: if "interaction_mode" not in payload and "chat_mode" not in payload:
mode_key = _chat_session_mode_setting_key( interaction_mode, _, _ = _resolve_mode_settings(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode" store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
) )
interaction_mode = normalize_interaction_mode(store.get_setting(mode_key))
if "specialist" not in payload: if "specialist" not in payload:
specialist_key = _chat_session_mode_setting_key( _, selected_specialist, _ = _resolve_mode_settings(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist" store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
) )
selected_specialist = normalize_requested_specialist(store.get_setting(specialist_key))
if "memory_mode" not in payload: if "memory_mode" not in payload:
memory_mode_key = _chat_session_mode_setting_key( _, _, memory_mode = _resolve_mode_settings(
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode" store=store,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
) )
memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)})
selected_specialist = _apply_specialist_flags(store, selected_specialist) selected_specialist = _apply_specialist_flags(store, selected_specialist)
if not text_raw and not attachments: if not text_raw and not attachments:
raise HTTPException(status_code=400, detail="text_or_attachments_required") raise HTTPException(status_code=400, detail="text_or_attachments_required")

View file

@ -45,9 +45,21 @@ const I18N = {
"attachments.toolModeMinRows": "触发工具模式最小行数", "attachments.toolModeMinRows": "触发工具模式最小行数",
"attachments.toolModeMaxBytes": "工具模式最大文件字节数", "attachments.toolModeMaxBytes": "工具模式最大文件字节数",
"attachments.sqlTimeoutMs": "SQL 超时(ms)", "attachments.sqlTimeoutMs": "SQL 超时(ms)",
"attachments.imageReplayCapChars": "图片结果回放上限字符数",
"attachments.videoReplayCapChars": "视频转写结果回放上限字符数",
"attachments.videoTranscriptChunkSize": "视频转写分块大小",
"attachments.videoTranscriptChunkOverlap": "视频转写分块重叠",
"attachments.archiveMaxDepth": "压缩包最大嵌套深度",
"attachments.archiveMaxFileCount": "压缩包最大文件数",
"attachments.archiveMaxEntryBytes": "压缩包单文件最大解压字节",
"attachments.archiveMaxTotalBytes": "压缩包总解压字节上限",
"attachments.highPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?", "attachments.highPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?",
"attachments.invalidNumber": "请输入有效正整数", "attachments.invalidNumber": "请输入有效正整数",
"attachments.sqlTimeoutHint": "硬超时(wall-clock)。范围 100..120000,默认 8000。命中会返回 sql_timeout 结构化错误。", "attachments.sqlTimeoutHint": "硬超时(wall-clock)。范围 100..120000,默认 8000。命中会返回 sql_timeout 结构化错误。",
"attachments.imageReplayCapHint": "历史轮次中,query_image_attachment 的 OCR/描述结果回放上限。范围 600..30000,默认 4000。",
"attachments.videoReplayCapHint": "历史轮次中,query_video_attachment 的 transcript 结果回放上限。范围 600..30000,默认 4000。",
"attachments.videoTranscriptChunkHint": "query_video_attachment(task=transcript) 默认使用该分块参数落库,便于后续 query_text_attachment 检索。",
"attachments.archivePolicyHint": "archive_processor 的统一预算:支持 zip/tar/tgz/gz,限制嵌套层级、文件数与解压体积。",
"attachments.loadError": "加载失败", "attachments.loadError": "加载失败",
"attachments.saved": "已保存", "attachments.saved": "已保存",
"attachments.save": "保存", "attachments.save": "保存",
@ -452,9 +464,21 @@ const I18N = {
"attachments.toolModeMinRows": "Min rows to trigger tool mode", "attachments.toolModeMinRows": "Min rows to trigger tool mode",
"attachments.toolModeMaxBytes": "Max file bytes for tool mode", "attachments.toolModeMaxBytes": "Max file bytes for tool mode",
"attachments.sqlTimeoutMs": "SQL timeout (ms)", "attachments.sqlTimeoutMs": "SQL timeout (ms)",
"attachments.imageReplayCapChars": "Image replay cap chars",
"attachments.videoReplayCapChars": "Video replay cap chars",
"attachments.videoTranscriptChunkSize": "Video transcript chunk size",
"attachments.videoTranscriptChunkOverlap": "Video transcript chunk overlap",
"attachments.archiveMaxDepth": "Archive max depth",
"attachments.archiveMaxFileCount": "Archive max file count",
"attachments.archiveMaxEntryBytes": "Archive max entry uncompressed bytes",
"attachments.archiveMaxTotalBytes": "Archive max total uncompressed bytes",
"attachments.highPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?", "attachments.highPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?",
"attachments.invalidNumber": "Please enter valid positive integers", "attachments.invalidNumber": "Please enter valid positive integers",
"attachments.sqlTimeoutHint": "Hard wall-clock timeout. Range 100..120000, default 8000. Timeout returns structured sql_timeout error.", "attachments.sqlTimeoutHint": "Hard wall-clock timeout. Range 100..120000, default 8000. Timeout returns structured sql_timeout error.",
"attachments.imageReplayCapHint": "Replay cap for query_image_attachment OCR/description results in historical context. Range 600..30000, default 4000.",
"attachments.videoReplayCapHint": "Replay cap for query_video_attachment transcript results in historical context. Range 600..30000, default 4000.",
"attachments.videoTranscriptChunkHint": "Default chunk parameters used by query_video_attachment(task=transcript) when persisting transcript chunks for query_text_attachment retrieval.",
"attachments.archivePolicyHint": "Unified archive_processor budget for zip/tar/tgz/gz: limits depth, file count and uncompressed size.",
"attachments.loadError": "Load failed", "attachments.loadError": "Load failed",
"attachments.saved": "Saved", "attachments.saved": "Saved",
"attachments.save": "Save", "attachments.save": "Save",
@ -6399,6 +6423,14 @@ async function renderAttachments() {
const toolMinRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); const toolMinRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const toolMaxBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); const toolMaxBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const sqlTimeoutInput = el("input", { class: "input", type: "number", min: "100", max: "120000", step: "1" }); const sqlTimeoutInput = el("input", { class: "input", type: "number", min: "100", max: "120000", step: "1" });
const imageReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" });
const videoReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" });
const videoTranscriptChunkSizeInput = el("input", { class: "input", type: "number", min: "1", max: "8000", step: "1" });
const videoTranscriptChunkOverlapInput = el("input", { class: "input", type: "number", min: "1", max: "4000", step: "1" });
const archiveMaxDepthInput = el("input", { class: "input", type: "number", min: "1", max: "10", step: "1" });
const archiveMaxFileCountInput = el("input", { class: "input", type: "number", min: "1", max: "20000", step: "1" });
const archiveMaxEntryBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const archiveMaxTotalBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const clampTimeout = (raw, fallback = 8000) => { const clampTimeout = (raw, fallback = 8000) => {
const n = parseInt(String(raw ?? "").trim(), 10); const n = parseInt(String(raw ?? "").trim(), 10);
@ -6421,6 +6453,14 @@ async function renderAttachments() {
const minRows = parsePositiveInt(toolMinRowsInput.value); const minRows = parsePositiveInt(toolMinRowsInput.value);
const maxBytes = parsePositiveInt(toolMaxBytesInput.value); const maxBytes = parsePositiveInt(toolMaxBytesInput.value);
const sqlTimeoutMs = parsePositiveInt(sqlTimeoutInput.value); const sqlTimeoutMs = parsePositiveInt(sqlTimeoutInput.value);
const imageReplayCapChars = parsePositiveInt(imageReplayCapInput.value);
const videoReplayCapChars = parsePositiveInt(videoReplayCapInput.value);
const videoTranscriptChunkSize = parsePositiveInt(videoTranscriptChunkSizeInput.value);
const videoTranscriptChunkOverlap = parsePositiveInt(videoTranscriptChunkOverlapInput.value);
const archiveMaxDepth = parsePositiveInt(archiveMaxDepthInput.value);
const archiveMaxFileCount = parsePositiveInt(archiveMaxFileCountInput.value);
const archiveMaxEntryBytes = parsePositiveInt(archiveMaxEntryBytesInput.value);
const archiveMaxTotalBytes = parsePositiveInt(archiveMaxTotalBytesInput.value);
if ( if (
rows === null || rows === null ||
cols === null || cols === null ||
@ -6429,7 +6469,15 @@ async function renderAttachments() {
previewRows === null || previewRows === null ||
minRows === null || minRows === null ||
maxBytes === null || maxBytes === null ||
sqlTimeoutMs === null sqlTimeoutMs === null ||
imageReplayCapChars === null ||
videoReplayCapChars === null ||
videoTranscriptChunkSize === null ||
videoTranscriptChunkOverlap === null ||
archiveMaxDepth === null ||
archiveMaxFileCount === null ||
archiveMaxEntryBytes === null ||
archiveMaxTotalBytes === null
) { ) {
throw new Error(t("attachments.invalidNumber")); throw new Error(t("attachments.invalidNumber"));
} }
@ -6446,6 +6494,14 @@ async function renderAttachments() {
tool_mode_min_rows: minRows, tool_mode_min_rows: minRows,
tool_mode_max_bytes: maxBytes, tool_mode_max_bytes: maxBytes,
sql_timeout_ms: clampTimeout(sqlTimeoutMs, 8000), sql_timeout_ms: clampTimeout(sqlTimeoutMs, 8000),
image_result_replay_cap_chars: Math.max(600, Math.min(30000, imageReplayCapChars)),
video_result_replay_cap_chars: Math.max(600, Math.min(30000, videoReplayCapChars)),
video_transcript_chunk_size: Math.max(1, Math.min(8000, videoTranscriptChunkSize)),
video_transcript_chunk_overlap: Math.max(1, Math.min(4000, videoTranscriptChunkOverlap)),
archive_max_depth: Math.max(1, Math.min(10, archiveMaxDepth)),
archive_max_file_count: Math.max(1, Math.min(20000, archiveMaxFileCount)),
archive_max_entry_bytes: Math.max(1, archiveMaxEntryBytes),
archive_max_total_uncompressed_bytes: Math.max(1, archiveMaxTotalBytes),
}; };
}; };
@ -6457,9 +6513,17 @@ async function renderAttachments() {
maxSheetsInput.value = String(l.max_excel_sheets || 50); maxSheetsInput.value = String(l.max_excel_sheets || 50);
largePreviewRowsInput.value = String(l.large_table_preview_rows || 20); largePreviewRowsInput.value = String(l.large_table_preview_rows || 20);
toolEnabledInput.checked = !!l.tool_mode_enabled; toolEnabledInput.checked = !!l.tool_mode_enabled;
toolMinRowsInput.value = String(l.tool_mode_min_rows || 20000); toolMinRowsInput.value = String(l.tool_mode_min_rows || 5000);
toolMaxBytesInput.value = String(l.tool_mode_max_bytes || 31457280); toolMaxBytesInput.value = String(l.tool_mode_max_bytes || 31457280);
sqlTimeoutInput.value = String(clampTimeout(l.sql_timeout_ms, 8000)); sqlTimeoutInput.value = String(clampTimeout(l.sql_timeout_ms, 8000));
imageReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.image_result_replay_cap_chars) || 4000)));
videoReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.video_result_replay_cap_chars) || 4000)));
videoTranscriptChunkSizeInput.value = String(Math.max(1, Math.min(8000, parsePositiveInt(l.video_transcript_chunk_size) || 1600)));
videoTranscriptChunkOverlapInput.value = String(Math.max(1, Math.min(4000, parsePositiveInt(l.video_transcript_chunk_overlap) || 200)));
archiveMaxDepthInput.value = String(Math.max(1, Math.min(10, parsePositiveInt(l.archive_max_depth) || 2)));
archiveMaxFileCountInput.value = String(Math.max(1, Math.min(20000, parsePositiveInt(l.archive_max_file_count) || 200)));
archiveMaxEntryBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_entry_bytes) || 10485760));
archiveMaxTotalBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_total_uncompressed_bytes) || 52428800));
}; };
const load = async () => { const load = async () => {
@ -6538,6 +6602,18 @@ async function renderAttachments() {
inputRow(t("attachments.toolModeMaxBytes"), toolMaxBytesInput), inputRow(t("attachments.toolModeMaxBytes"), toolMaxBytesInput),
inputRow(t("attachments.sqlTimeoutMs"), sqlTimeoutInput), inputRow(t("attachments.sqlTimeoutMs"), sqlTimeoutInput),
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.sqlTimeoutHint") }), el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.sqlTimeoutHint") }),
inputRow(t("attachments.imageReplayCapChars"), imageReplayCapInput),
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.imageReplayCapHint") }),
inputRow(t("attachments.videoReplayCapChars"), videoReplayCapInput),
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.videoReplayCapHint") }),
inputRow(t("attachments.videoTranscriptChunkSize"), videoTranscriptChunkSizeInput),
inputRow(t("attachments.videoTranscriptChunkOverlap"), videoTranscriptChunkOverlapInput),
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.videoTranscriptChunkHint") }),
inputRow(t("attachments.archiveMaxDepth"), archiveMaxDepthInput),
inputRow(t("attachments.archiveMaxFileCount"), archiveMaxFileCountInput),
inputRow(t("attachments.archiveMaxEntryBytes"), archiveMaxEntryBytesInput),
inputRow(t("attachments.archiveMaxTotalBytes"), archiveMaxTotalBytesInput),
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.archivePolicyHint") }),
el("div", { class: "row", style: "gap:8px;margin-top:12px;flex-wrap:wrap;" }, [loadBtn, resetBtn, saveBtn]), el("div", { class: "row", style: "gap:8px;margin-top:12px;flex-wrap:wrap;" }, [loadBtn, resetBtn, saveBtn]),
el("div", { style: "height:8px" }), el("div", { style: "height:8px" }),
status, status,

View file

@ -288,8 +288,14 @@ const CHAT_URL_SCOPE_KEY = "ops_chat_url_scope";
const CHAT_SPECIALIST_PREF_KEY = "ops_chat_specialist_pref"; const CHAT_SPECIALIST_PREF_KEY = "ops_chat_specialist_pref";
const CHAT_INTERACTION_MODE_KEY = "ops_chat_interaction_mode"; const CHAT_INTERACTION_MODE_KEY = "ops_chat_interaction_mode";
const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode"; const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode";
const CHAT_REASONING_TOGGLE_KEY = "ops_chat_reasoning_toggle";
const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = false; const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = false;
let adminChatShowToolOutput = ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT; const REASONING_BLOCK_MAX_CHARS = 12000;
const CHAT_ENABLE_WIKI_EVENT_POLLER = false;
const _rt = String(localStorage.getItem(CHAT_REASONING_TOGGLE_KEY) || "").trim().toLowerCase();
let adminChatShowToolOutput = _rt
? ["1", "true", "yes", "on"].includes(_rt)
: ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT;
let authSession = null; let authSession = null;
function _toolSummaryTitle(role) { function _toolSummaryTitle(role) {
@ -303,10 +309,33 @@ function _normalizeEventType(v) {
} }
function _collapsedBlockNode(title, text) { function _collapsedBlockNode(title, text) {
const raw = String(text || "");
const clipped = raw.length > REASONING_BLOCK_MAX_CHARS ? raw.slice(0, REASONING_BLOCK_MAX_CHARS) : raw;
const suffix =
raw.length > REASONING_BLOCK_MAX_CHARS
? `\n\n[truncated ${raw.length - REASONING_BLOCK_MAX_CHARS} chars to keep UI responsive]`
: "";
const box = document.createElement("div"); const box = document.createElement("div");
box.className = "chat-msg__reasoning-block"; box.className = "chat-msg__reasoning-block";
box.appendChild(el("div", { class: "chat-msg__reasoning-title", text: String(title || "") })); box.appendChild(el("div", { class: "chat-msg__reasoning-title", text: String(title || "") }));
box.appendChild(el("pre", { class: "chat-msg__reasoning-pre", text: String(text || "") || "—" })); const pre = el("pre", { class: "chat-msg__reasoning-pre", text: (clipped + suffix) || "—" });
box.appendChild(pre);
if (raw.length > REASONING_BLOCK_MAX_CHARS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm";
btn.style.marginTop = "6px";
btn.textContent = currentLang === "zh" ? "展开全文(可能较慢)" : "Load full text (may be slow)";
btn.addEventListener("click", () => {
btn.disabled = true;
btn.textContent = currentLang === "zh" ? "加载中..." : "Loading...";
requestAnimationFrame(() => {
pre.textContent = raw || "—";
btn.remove();
});
});
box.appendChild(btn);
}
return box; return box;
} }
@ -1206,12 +1235,14 @@ function createStreamStitcher() {
}; };
} }
function formatToolPanelText(name, payload) { function formatToolPanelText(name, payload, options = {}) {
const streamMode = !!(options && options.streamMode);
const truncateToolPanel = (s) => { const truncateToolPanel = (s) => {
const raw = String(s || "").trim(); const raw = String(s || "").trim();
const maxChars = 50; if (streamMode) return raw;
const maxChars = 4000;
if (raw.length <= maxChars) return raw; if (raw.length <= maxChars) return raw;
return `${raw.slice(0, maxChars)}…(详情请重新阅读)`; return `${raw.slice(0, maxChars)}\n\n…(为保证界面性能,已截断 ${raw.length - maxChars} 字符)`;
}; };
const n = String(name || "").trim() || "tool"; const n = String(name || "").trim() || "tool";
const p = payload && typeof payload === "object" ? payload : {}; const p = payload && typeof payload === "object" ? payload : {};
@ -1252,7 +1283,7 @@ function formatToolPanelText(name, payload) {
body = String(r || ""); body = String(r || "");
} }
} }
body = normalizeStreamText(body).replace(/\\n/g, "").replace(/\n/g, ""); body = normalizeStreamText(body);
return `${n}\n${truncateToolPanel(body)}`.trim(); return `${n}\n${truncateToolPanel(body)}`.trim();
} }
@ -1870,12 +1901,12 @@ async function renderChatUi() {
}); });
modeSelect.appendChild(el("option", { value: "comprehensive", text: t("chat.modeComprehensive") })); modeSelect.appendChild(el("option", { value: "comprehensive", text: t("chat.modeComprehensive") }));
modeSelect.appendChild(el("option", { value: "expert", text: t("chat.modeExpert") })); modeSelect.appendChild(el("option", { value: "expert", text: t("chat.modeExpert") }));
const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "comprehensive").toLowerCase(); const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "expert").toLowerCase();
modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "comprehensive"; modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "expert";
const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default"); localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default");
modeSelect.addEventListener("change", () => { modeSelect.addEventListener("change", () => {
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive")); localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert"));
saveSessionModePreference(); saveSessionModePreference();
}); });
const applySpecialistOptions = () => { const applySpecialistOptions = () => {
@ -1944,7 +1975,7 @@ async function renderChatUi() {
if (String(modeSelect.value || "").toLowerCase() !== "expert") { if (String(modeSelect.value || "").toLowerCase() !== "expert") {
specialistSelect.value = "generalist"; specialistSelect.value = "generalist";
} }
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive")); localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert"));
localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist")); localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist"));
const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default"); localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default");
@ -1955,9 +1986,9 @@ async function renderChatUi() {
if (!activeId) return; if (!activeId) return;
try { try {
await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, { await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, {
interaction_mode: String(modeSelect.value || "comprehensive"), interaction_mode: String(modeSelect.value || "expert"),
specialist: specialist:
String(modeSelect.value || "comprehensive").toLowerCase() === "expert" String(modeSelect.value || "expert").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist") ? String(specialistSelect.value || "generalist")
: "generalist", : "generalist",
memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"), memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
@ -1975,7 +2006,7 @@ async function renderChatUi() {
]); ]);
const syncModeSpecialistUi = () => { const syncModeSpecialistUi = () => {
const m = String(modeSelect.value || "comprehensive").toLowerCase(); const m = String(modeSelect.value || "expert").toLowerCase();
const showSpecialist = m === "expert"; const showSpecialist = m === "expert";
specialistLabelEl.style.display = showSpecialist ? "" : "none"; specialistLabelEl.style.display = showSpecialist ? "" : "none";
specialistSelect.style.display = showSpecialist ? "" : "none"; specialistSelect.style.display = showSpecialist ? "" : "none";
@ -2069,22 +2100,12 @@ async function renderChatUi() {
topTopic = String(k || ""); topTopic = String(k || "");
} }
} }
const actions = [ const actions = [];
{
label: t("chat.wikiViewMerged"),
onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: "inbox/merged-turns.md" }),
},
];
if (topTopic) {
actions.push({
label: t("chat.wikiViewTopic", { topic: topTopic }),
onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: `topics/auto-${topTopic}.md` }),
});
}
return { kind: "info", text: t("chat.wikiToastMerged", { merged: String(merged), skipped: String(skipped) }), actions }; return { kind: "info", text: t("chat.wikiToastMerged", { merged: String(merged), skipped: String(skipped) }), actions };
}; };
const pollWikiEvents = async () => { const pollWikiEvents = async () => {
if (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
const sid = String(activeId || ""); const sid = String(activeId || "");
if (!sid) return; if (!sid) return;
const after = String((wikiAfterFinishedAt && wikiAfterFinishedAt[sid]) || ""); const after = String((wikiAfterFinishedAt && wikiAfterFinishedAt[sid]) || "");
@ -2129,6 +2150,7 @@ async function renderChatUi() {
}; };
const startWikiPoller = () => { const startWikiPoller = () => {
if (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
if (wikiPollTimerId != null) return; if (wikiPollTimerId != null) return;
wikiPollTimerId = setInterval(pollWikiEvents, 2200); wikiPollTimerId = setInterval(pollWikiEvents, 2200);
// kick once // kick once
@ -2171,6 +2193,7 @@ async function renderChatUi() {
toolToggleBtn.addEventListener("click", () => { toolToggleBtn.addEventListener("click", () => {
showToolOutput = !showToolOutput; showToolOutput = !showToolOutput;
adminChatShowToolOutput = showToolOutput; adminChatShowToolOutput = showToolOutput;
localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0");
syncToolToggleBtn(); syncToolToggleBtn();
const isStreaming = composerShell.classList.contains("chat-composer-shell--busy"); const isStreaming = composerShell.classList.contains("chat-composer-shell--busy");
if (isStreaming) { if (isStreaming) {
@ -2464,6 +2487,7 @@ async function renderChatUi() {
statusBar.textContent = t("chat.loading"); statusBar.textContent = t("chat.loading");
const resp = await apiPost("/admin/api/chat/sessions", {}); const resp = await apiPost("/admin/api/chat/sessions", {});
adoptCreatedSession(resp); adoptCreatedSession(resp);
await loadSessionModePreference();
paintSessions(); paintSessions();
messagesEl.innerHTML = ""; messagesEl.innerHTML = "";
messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") })); messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
@ -2614,7 +2638,7 @@ async function renderChatUi() {
attachments: Array.isArray(attachments) ? attachments : [], attachments: Array.isArray(attachments) ? attachments : [],
idempotencyKey: String(idempotencyKey || `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`), idempotencyKey: String(idempotencyKey || `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`),
thinking: "default", thinking: "default",
interaction_mode: String(interactionMode || "comprehensive"), interaction_mode: String(interactionMode || "expert"),
specialist: String(specialist || "generalist"), specialist: String(specialist || "generalist"),
memory_mode: String(memoryMode || "default"), memory_mode: String(memoryMode || "default"),
}, },
@ -2710,7 +2734,7 @@ async function renderChatUi() {
let currentStreamAbortController = null; let currentStreamAbortController = null;
let currentWsTransport = null; let currentWsTransport = null;
let currentAbortMeta = { sessionId: "", runId: "" }; let currentAbortMeta = { sessionId: "", runId: "" };
const WS_CHAT_SEND_TIMEOUT_MS = 60000; const WS_CHAT_SEND_TIMEOUT_MS = 180000;
const isAbortError = (err) => { const isAbortError = (err) => {
const name = String(err && err.name ? err.name : ""); const name = String(err && err.name ? err.name : "");
const msg = String(err && err.message ? err.message : err || ""); const msg = String(err && err.message ? err.message : err || "");
@ -2856,10 +2880,12 @@ async function renderChatUi() {
const chunks = []; const chunks = [];
for (const seg of chatStreamSegments) { for (const seg of chatStreamSegments) {
if (!seg || seg.type !== "text") continue; if (!seg || seg.type !== "text") continue;
const txt = String(seg.text || "").trim(); const txt = String(seg.text || "");
if (txt) chunks.push(txt); if (txt) chunks.push(txt);
} }
return decodeEscapedNewlines(chunks.join("\n")); // Keep 1:1 character mapping with original text segments.
// Never inject separators here; otherwise typewriter offsets drift.
return decodeEscapedNewlines(chunks.join(""));
}; };
const _renderStreamCompositeNow = () => { const _renderStreamCompositeNow = () => {
const bubble = ensureStreamBubble(); const bubble = ensureStreamBubble();
@ -2902,7 +2928,7 @@ async function renderChatUi() {
const showLen = Math.max(0, Math.min(full.length, streamDisplayShown.length - textIdx)); const showLen = Math.max(0, Math.min(full.length, streamDisplayShown.length - textIdx));
const shown = full.slice(0, showLen); const shown = full.slice(0, showLen);
textIdx += full.length; textIdx += full.length;
if (!shown.trim()) continue; if (!shown) continue;
blocks.push(`<div class="chat-msg__plain">${escapeHtml(decodeEscapedNewlines(shown))}</div>`); blocks.push(`<div class="chat-msg__plain">${escapeHtml(decodeEscapedNewlines(shown))}</div>`);
} else if (seg.type === "tool") { } else if (seg.type === "tool") {
if (!showToolOutput) continue; if (!showToolOutput) continue;
@ -2968,8 +2994,9 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
renderStreamComposite(); renderStreamComposite();
return; return;
} }
// Slightly faster typing while keeping typewriter feel for正文. // Adaptive typing speed: when backlog grows, catch up quickly
const step = 2; // to avoid fragmented/laggy streaming and end-of-turn text bursts.
const step = Math.max(4, Math.min(180, Math.ceil(remain / 10)));
streamDisplayShown = streamDisplayTarget.slice(0, streamDisplayShown.length + step); streamDisplayShown = streamDisplayTarget.slice(0, streamDisplayShown.length + step);
renderStreamComposite(); renderStreamComposite();
if (typingTimerId != null) { if (typingTimerId != null) {
@ -2979,7 +3006,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
typingTimerId = setTimeout(() => { typingTimerId = setTimeout(() => {
typingTimerId = null; typingTimerId = null;
_scheduleTypingTick(); _scheduleTypingTick();
}, 22); }, 14);
}); });
}; };
const setStreamText = (text) => { const setStreamText = (text) => {
@ -2996,11 +3023,12 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
const appendToolSegment = (payload) => { const appendToolSegment = (payload) => {
const p = payload && typeof payload === "object" ? payload : {}; const p = payload && typeof payload === "object" ? payload : {};
const name = String(p.name || "tool"); const name = String(p.name || "tool");
const liveTag = currentLang === "zh" ? "[实时全量]" : "[LIVE FULL]";
const key = `${name}:${++toolSeq}`; const key = `${name}:${++toolSeq}`;
const rawPayload = p.payload != null ? p.payload : p; const rawPayload = p.payload != null ? p.payload : p;
const body = formatToolPanelText(name, rawPayload); const body = formatToolPanelText(name, rawPayload, { streamMode: true });
const sqlAudit = extractSqlAuditPayload(rawPayload); const sqlAudit = extractSqlAuditPayload(rawPayload);
chatStreamSegments.push({ type: "tool", key, title: name, body, sqlAudit }); chatStreamSegments.push({ type: "tool", key, title: `${name} ${liveTag}`, body, sqlAudit });
}; };
const appendFinalAssistant = async (message, fallbackText) => { const appendFinalAssistant = async (message, fallbackText) => {
const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false }); const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false });
@ -3053,10 +3081,10 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
sessionId: activeId, sessionId: activeId,
text: userText, text: userText,
attachments: attachmentPayload, attachments: attachmentPayload,
interactionMode: String(modeSelect.value || "comprehensive"), interactionMode: String(modeSelect.value || "expert"),
idempotencyKey: String(turnId || ""), idempotencyKey: String(turnId || ""),
specialist: specialist:
String(modeSelect.value || "comprehensive").toLowerCase() === "expert" String(modeSelect.value || "expert").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist") ? String(specialistSelect.value || "generalist")
: "generalist", : "generalist",
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"), memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
@ -3144,6 +3172,11 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
chatStream = ""; chatStream = "";
chatRunId = null; chatRunId = null;
chatStreamSegments = []; chatStreamSegments = [];
// Final assistant body may arrive before reasoning/tool rows are fully persisted.
// Reload once shortly after final to avoid "reasoning disappears until session switch".
setTimeout(() => {
loadMessagesForActive().catch(() => {});
}, 350);
if (!ok) statusBar.textContent = ""; if (!ok) statusBar.textContent = "";
return; return;
} }
@ -3209,6 +3242,24 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
}, 350); }, 350);
return doneMeta; return doneMeta;
} }
// WS timeout may happen while backend still computes and persists final reply.
// Try recovering from persisted history before surfacing a hard error.
if (emsg.includes("ws_send_timeout")) {
try {
for (let i = 0; i < 12; i++) {
if (await _recoverLatestAssistantFromHistory()) {
statusBar.textContent =
currentLang === "zh"
? "请求超时,已从历史补齐本轮结果。"
: "Request timed out; recovered this turn from history.";
return doneMeta || { __transport: "ws_timeout_history_recovered" };
}
await _sleep(600 + i * 400);
}
} catch (_) {
// fall through to generic ws-like failure recovery
}
}
// WS may time out/close while backend persists final message slightly later. // WS may time out/close while backend persists final message slightly later.
// Poll history briefly before surfacing hard failure. // Poll history briefly before surfacing hard failure.
try { try {

View file

@ -6,7 +6,7 @@
}, },
"entries": { "entries": {
"memory-wiki": { "memory-wiki": {
"wiki_root": "oclaw/docs/memory-system/wiki", "wiki_root": "data/wiki",
"max_search_results": 20, "max_search_results": 20,
"max_get_lines": 800, "max_get_lines": 800,
"auto": { "auto": {
@ -20,7 +20,15 @@
"large_table_preview_rows": 20, "large_table_preview_rows": 20,
"tool_mode_enabled": true, "tool_mode_enabled": true,
"tool_mode_min_rows": 20000, "tool_mode_min_rows": 20000,
"tool_mode_max_bytes": 31457280 "tool_mode_max_bytes": 31457280,
"image_result_replay_cap_chars": 4000,
"video_result_replay_cap_chars": 4000,
"video_transcript_chunk_size": 1600,
"video_transcript_chunk_overlap": 200,
"archive_max_depth": 2,
"archive_max_file_count": 200,
"archive_max_entry_bytes": 10485760,
"archive_max_total_uncompressed_bytes": 52428800
} }
}, },
"inject": { "inject": {

View file

@ -0,0 +1,30 @@
from __future__ import annotations
import importlib.util
import sysconfig
from pathlib import Path
def _load_stdlib_platform() -> object | None:
stdlib_dir = Path(sysconfig.get_paths().get("stdlib") or "")
platform_py = (stdlib_dir / "platform.py").resolve()
if not platform_py.exists():
return None
spec = importlib.util.spec_from_file_location("_stdlib_platform", str(platform_py))
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_stdlib = _load_stdlib_platform()
if _stdlib is not None:
for _name in dir(_stdlib):
if _name in {"__name__", "__package__", "__loader__", "__spec__", "__file__", "__cached__"}:
continue
globals()[_name] = getattr(_stdlib, _name)
__all__ = list(getattr(_stdlib, "__all__", []))
else:
__all__ = []

View file

@ -0,0 +1,202 @@
from __future__ import annotations
import gzip
import io
import tarfile
import zipfile
from pathlib import PurePosixPath
from typing import Any, Callable
MAX_ARCHIVE_DEPTH = 2
MAX_ARCHIVE_FILE_COUNT = 200
MAX_ARCHIVE_ENTRY_BYTES = 10 * 1024 * 1024
MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
MAX_ARCHIVE_MEMBER_NAME_LENGTH = 240
def is_safe_archive_member_name(name: str) -> bool:
n = str(name or "").replace("\\", "/").strip()
if not n:
return False
if len(n) > MAX_ARCHIVE_MEMBER_NAME_LENGTH:
return False
if any(ord(ch) < 32 for ch in n):
return False
p = PurePosixPath(n)
if p.is_absolute():
return False
if any(part in ("..", "") for part in p.parts):
return False
return True
def detect_archive_kind(name: str) -> str | None:
n = str(name or "").strip().lower()
if n.endswith(".tar.gz") or n.endswith(".tgz"):
return "tgz"
if n.endswith(".zip"):
return "zip"
if n.endswith(".tar"):
return "tar"
if n.endswith(".gz"):
return "gz"
if n.endswith(".rar"):
return "rar"
if n.endswith(".7z"):
return "7z"
return None
def sniff_archive_kind(data: bytes) -> str | None:
b = bytes(data or b"")
if len(b) >= 4 and b[:4] == b"PK\x03\x04":
return "zip"
if len(b) >= 2 and b[:2] == b"\x1f\x8b":
return "gz"
if len(b) >= 6 and b[:6] == b"Rar!\x1a\x07":
return "rar"
if len(b) >= 6 and b[:6] == b"7z\xbc\xaf\x27\x1c":
return "7z"
if len(b) >= 262 and b[257:262] == b"ustar":
return "tar"
return None
def process_archive(
*,
archive_name: str,
archive_data: bytes,
process_member: Callable[[str, bytes, int], list[dict[str, Any]]],
depth: int = 0,
limits: dict[str, int] | None = None,
) -> list[dict[str, Any]]:
lim = limits or {}
max_depth = int(lim.get("max_depth") or MAX_ARCHIVE_DEPTH)
max_file_count = int(lim.get("max_file_count") or MAX_ARCHIVE_FILE_COUNT)
max_entry_bytes = int(lim.get("max_entry_bytes") or MAX_ARCHIVE_ENTRY_BYTES)
max_total_uncompressed_bytes = int(
lim.get("max_total_uncompressed_bytes") or MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
)
kind_from_sig = sniff_archive_kind(archive_data)
kind_from_name = detect_archive_kind(archive_name)
# For .tgz/.tar.gz, signature looks like gzip; keep richer name hint.
if kind_from_name == "tgz" and kind_from_sig == "gz":
kind = "tgz"
else:
kind = kind_from_sig or kind_from_name
if kind is None:
return []
if depth > max_depth:
return [{"type": "text", "name": "zip-error", "content": f"ZIP nesting too deep (>{max_depth})"}]
out: list[dict[str, Any]] = []
def _err(msg: str, *, error_code: str) -> list[dict[str, Any]]:
base = {
"type": "text",
"name": ("zip-error" if kind == "zip" else "archive-error"),
"error_code": str(error_code or "archive_error"),
"content": (
f"[ArchiveError]\n- error_code: {error_code}\n- format: {kind}\n- detail: {msg}"
),
}
return [base]
try:
if kind in {"rar", "7z"}:
raise ValueError(
f"unsupported archive format: {kind}; currently supported formats are zip/tar/tgz/gz"
)
if kind == "zip":
with zipfile.ZipFile(io.BytesIO(archive_data)) as zf:
infos = [x for x in zf.infolist() if not x.is_dir()]
if len(infos) > max_file_count:
raise ValueError(f"ZIP has too many files ({len(infos)} > {max_file_count})")
total_uncompressed = 0
for info in infos:
if not is_safe_archive_member_name(info.filename):
raise ValueError(f"ZIP contains unsafe path: {info.filename}")
fsize = int(info.file_size or 0)
total_uncompressed += fsize
if fsize > max_entry_bytes:
raise ValueError(f"ZIP entry too large: {info.filename} ({fsize} > {max_entry_bytes})")
if total_uncompressed > max_total_uncompressed_bytes:
raise ValueError(
f"ZIP total uncompressed size too large ({total_uncompressed} > {max_total_uncompressed_bytes})"
)
with zf.open(info) as f:
out.extend(process_member(info.filename, f.read(), depth + 1))
return out
if kind in {"tar", "tgz"}:
mode = "r:gz" if kind == "tgz" else "r:"
with tarfile.open(fileobj=io.BytesIO(archive_data), mode=mode) as tf:
all_members = list(tf.getmembers())
for m in all_members:
if m.issym() or m.islnk():
raise ValueError(f"TAR contains link entry: {m.name}")
if m.ischr() or m.isblk() or m.isfifo():
raise ValueError(f"TAR contains device/fifo entry: {m.name}")
members = [m for m in all_members if m.isfile()]
if len(members) > max_file_count:
raise ValueError(f"TAR has too many files ({len(members)} > {max_file_count})")
total_uncompressed = 0
for m in members:
if not is_safe_archive_member_name(m.name):
raise ValueError(f"TAR contains unsafe path: {m.name}")
fsize = int(m.size or 0)
total_uncompressed += fsize
if fsize > max_entry_bytes:
raise ValueError(f"TAR entry too large: {m.name} ({fsize} > {max_entry_bytes})")
if total_uncompressed > max_total_uncompressed_bytes:
raise ValueError(
f"TAR total uncompressed size too large ({total_uncompressed} > {max_total_uncompressed_bytes})"
)
ext = tf.extractfile(m)
if ext is None:
continue
out.extend(process_member(m.name, ext.read(), depth + 1))
return out
if kind == "gz":
raw = gzip.decompress(archive_data)
if len(raw) > max_entry_bytes:
raise ValueError(f"GZ entry too large ({len(raw)} > {max_entry_bytes})")
name = str(archive_name or "file.gz")
inner_name = name[:-3] if name.lower().endswith(".gz") else f"{name}.out"
out.extend(process_member(inner_name, raw, depth + 1))
return out
except Exception as e:
s = str(e).lower()
code = "archive_parse_failed"
if "unsupported archive format" in s:
code = "archive_unsupported_format"
elif "unsafe path" in s:
code = "archive_path_traversal"
elif "nesting too deep" in s:
code = "archive_max_depth_exceeded"
elif "too many files" in s:
code = "archive_max_file_count_exceeded"
elif "entry too large" in s:
code = "archive_max_entry_bytes_exceeded"
elif "total uncompressed size too large" in s:
code = "archive_max_total_uncompressed_bytes_exceeded"
elif "link entry" in s:
code = "archive_link_entry_forbidden"
elif "device/fifo entry" in s:
code = "archive_special_entry_forbidden"
return _err(str(e), error_code=code)
return out
__all__ = [
"MAX_ARCHIVE_DEPTH",
"MAX_ARCHIVE_ENTRY_BYTES",
"MAX_ARCHIVE_FILE_COUNT",
"MAX_ARCHIVE_MEMBER_NAME_LENGTH",
"MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES",
"detect_archive_kind",
"sniff_archive_kind",
"is_safe_archive_member_name",
"process_archive",
]

View file

@ -6,10 +6,10 @@ import io
import json import json
import os import os
import re import re
import subprocess
import zipfile import zipfile
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pathlib import PurePosixPath
from typing import Any from typing import Any
import pandas as pd import pandas as pd
@ -18,8 +18,24 @@ from PIL import Image
from PyPDF2 import PdfReader from PyPDF2 import PdfReader
from oclaw.platform.config.paths import PROJECT_ROOT from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.platform.files.archive_processor import (
MAX_ARCHIVE_DEPTH,
MAX_ARCHIVE_ENTRY_BYTES,
MAX_ARCHIVE_FILE_COUNT,
MAX_ARCHIVE_MEMBER_NAME_LENGTH,
MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES,
detect_archive_kind,
is_safe_archive_member_name,
process_archive,
)
from oclaw.platform.files.attachment_assets import AttachmentAssetStore from oclaw.platform.files.attachment_assets import AttachmentAssetStore
from oclaw.platform.files.tabular_attachment_store import save_dataframe, save_workbook from oclaw.platform.files.tabular_attachment_store import save_dataframe, save_workbook
from oclaw.platform.files.text_attachment_store import (
DEFAULT_TEXT_CHUNK_OVERLAP,
DEFAULT_TEXT_CHUNK_SIZE,
DEFAULT_TEXT_INLINE_MAX_CHARS,
save_text_document,
)
def _sniff_image_mime(data: bytes) -> str | None: def _sniff_image_mime(data: bytes) -> str | None:
@ -36,25 +52,101 @@ def _sniff_image_mime(data: bytes) -> str | None:
return None return None
def _ffprobe_exists() -> bool:
try:
p = subprocess.run(["ffprobe", "-version"], capture_output=True, text=True, timeout=3)
return p.returncode == 0
except Exception:
return False
def _extract_video_meta_from_ffprobe(obj: dict[str, Any]) -> dict[str, Any]:
fmt = obj.get("format") if isinstance(obj.get("format"), dict) else {}
streams = obj.get("streams") if isinstance(obj.get("streams"), list) else []
duration_sec = None
if isinstance(fmt, dict) and fmt.get("duration") is not None:
try:
duration_sec = float(fmt.get("duration"))
except Exception:
duration_sec = None
width = None
height = None
fps = None
for s in streams:
if not isinstance(s, dict):
continue
if str(s.get("codec_type") or "") != "video":
continue
if s.get("width") is not None and s.get("height") is not None:
try:
width = int(s.get("width") or 0) or None
height = int(s.get("height") or 0) or None
except Exception:
width = width
height = height
fr = str(s.get("avg_frame_rate") or s.get("r_frame_rate") or "").strip()
if fr and fr != "0/0" and "/" in fr:
try:
a, b = fr.split("/", 1)
fa = float(a)
fb = float(b)
if fb:
fps = fa / fb
except Exception:
fps = fps
break
out: dict[str, Any] = {}
if duration_sec is not None:
out["duration_sec"] = duration_sec
if width is not None:
out["width"] = width
if height is not None:
out["height"] = height
if fps is not None:
out["fps"] = fps
return out
def _ffprobe_video_meta(path: Path) -> dict[str, Any]:
if not _ffprobe_exists():
return {}
try:
p = subprocess.run(
["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)],
capture_output=True,
text=True,
timeout=8,
)
if p.returncode != 0:
return {}
obj = json.loads(p.stdout or "{}")
if not isinstance(obj, dict):
return {}
return _extract_video_meta_from_ffprobe(obj)
except Exception:
return {}
MAX_TEXT_CHARS = 12_000 MAX_TEXT_CHARS = 12_000
EXCEL_PREVIEW_ROWS = 80 EXCEL_PREVIEW_ROWS = 80
LARGE_TABLE_PREVIEW_ROWS = 20 LARGE_TABLE_PREVIEW_ROWS = 20
MAX_ZIP_DEPTH = 2 MAX_ZIP_DEPTH = MAX_ARCHIVE_DEPTH
MAX_ZIP_FILE_COUNT = 200 MAX_ZIP_FILE_COUNT = MAX_ARCHIVE_FILE_COUNT
MAX_ZIP_ENTRY_BYTES = 10 * 1024 * 1024 MAX_ZIP_ENTRY_BYTES = MAX_ARCHIVE_ENTRY_BYTES
MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 50 * 1024 * 1024 MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
DEFAULT_TABULAR_ROWS_READ = 5000 DEFAULT_TABULAR_ROWS_READ = 5000
DEFAULT_TABULAR_COLUMNS = 200 DEFAULT_TABULAR_COLUMNS = 200
DEFAULT_TABULAR_CELL_CHARS = 500 DEFAULT_TABULAR_CELL_CHARS = 500
DEFAULT_TABULAR_TOOL_MODE_ENABLED = True DEFAULT_TABULAR_TOOL_MODE_ENABLED = True
DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 20_000 DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000
DEFAULT_TABULAR_TOOL_MODE_MAX_BYTES = 30 * 1024 * 1024 DEFAULT_TABULAR_TOOL_MODE_MAX_BYTES = 30 * 1024 * 1024
DEFAULT_LARGE_TABLE_PREVIEW_ROWS = LARGE_TABLE_PREVIEW_ROWS DEFAULT_LARGE_TABLE_PREVIEW_ROWS = LARGE_TABLE_PREVIEW_ROWS
MAX_ZIP_MEMBER_NAME_LENGTH = 240 MAX_ZIP_MEMBER_NAME_LENGTH = MAX_ARCHIVE_MEMBER_NAME_LENGTH
MAX_EXCEL_ZIP_FILE_COUNT = 500 MAX_EXCEL_ZIP_FILE_COUNT = 500
MAX_EXCEL_ZIP_ENTRY_BYTES = 20 * 1024 * 1024 MAX_EXCEL_ZIP_ENTRY_BYTES = 20 * 1024 * 1024
MAX_EXCEL_ZIP_TOTAL_UNCOMPRESSED_BYTES = 120 * 1024 * 1024 MAX_EXCEL_ZIP_TOTAL_UNCOMPRESSED_BYTES = 120 * 1024 * 1024
DEFAULT_MAX_EXCEL_SHEETS = 50 DEFAULT_MAX_EXCEL_SHEETS = 50
DEFAULT_TEXT_QUERY_TOP_K = 5
def _truncate_text(text: str, limit: int = MAX_TEXT_CHARS) -> str: def _truncate_text(text: str, limit: int = MAX_TEXT_CHARS) -> str:
@ -64,6 +156,24 @@ def _truncate_text(text: str, limit: int = MAX_TEXT_CHARS) -> str:
return t[:limit] + f"\n\n...[truncated to {limit} chars]" return t[:limit] + f"\n\n...[truncated to {limit} chars]"
def _text_ref_summary(*, name: str, chars: int, chunks: int, source_kind: str, preview: str, top_k: int) -> str:
return _truncate_text(
(
f"# Text Document Summary\n"
f"- name: {name}\n"
f"- source_kind: {source_kind}\n"
f"- chars: {chars}\n"
f"- chunks: {chunks}\n"
f"- mode: text_ref\n"
f"- query_tool: query_text_attachment\n"
f"- note: use `text_id` from accompanying `text_ref` attachment; recommended top_k <= {top_k}\n\n"
f"## Preview (first {min(len(preview), 1500)} chars)\n"
f"{preview[:1500]}"
),
limit=MAX_TEXT_CHARS,
)
def _decode_text_bytes(data: bytes) -> str: def _decode_text_bytes(data: bytes) -> str:
try: try:
return data.decode("utf-8") return data.decode("utf-8")
@ -120,6 +230,26 @@ def _attachments_limits() -> dict[str, Any]:
"tool_mode_max_bytes": _safe_int( "tool_mode_max_bytes": _safe_int(
tabular_cfg.get("tool_mode_max_bytes"), DEFAULT_TABULAR_TOOL_MODE_MAX_BYTES, max_value=500 * 1024 * 1024 tabular_cfg.get("tool_mode_max_bytes"), DEFAULT_TABULAR_TOOL_MODE_MAX_BYTES, max_value=500 * 1024 * 1024
), ),
"text_inline_max_chars": _safe_int(
tabular_cfg.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000
),
"text_chunk_size": _safe_int(tabular_cfg.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
"text_chunk_overlap": _safe_int(
tabular_cfg.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000
),
"text_query_top_k": _safe_int(tabular_cfg.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
"archive_max_depth": _safe_int(tabular_cfg.get("archive_max_depth"), MAX_ARCHIVE_DEPTH, max_value=10),
"archive_max_file_count": _safe_int(
tabular_cfg.get("archive_max_file_count"), MAX_ARCHIVE_FILE_COUNT, max_value=20_000
),
"archive_max_entry_bytes": _safe_int(
tabular_cfg.get("archive_max_entry_bytes"), MAX_ARCHIVE_ENTRY_BYTES, max_value=2_000_000_000
),
"archive_max_total_uncompressed_bytes": _safe_int(
tabular_cfg.get("archive_max_total_uncompressed_bytes"),
MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES,
max_value=5_000_000_000,
),
} }
@ -160,6 +290,11 @@ def _dataframe_summary_text(
dtypes = [f"{str(k)}:{str(v)}" for k, v in df.dtypes.items()] dtypes = [f"{str(k)}:{str(v)}" for k, v in df.dtypes.items()]
show_rows = max(1, int(preview_rows or EXCEL_PREVIEW_ROWS)) show_rows = max(1, int(preview_rows or EXCEL_PREVIEW_ROWS))
sample = df.head(show_rows).to_string(index=False) sample = df.head(show_rows).to_string(index=False)
rows_scope_line = (
f"## Preview (first {min(rows, show_rows)} rows)"
if rows > show_rows
else f"## Full table included ({rows} rows)"
)
body = ( body = (
f"# Table Summary\n" f"# Table Summary\n"
f"- rows: {rows}\n" f"- rows: {rows}\n"
@ -169,7 +304,7 @@ def _dataframe_summary_text(
f"- clipped_cells: {'yes' if clipped_cells else 'no'}\n" f"- clipped_cells: {'yes' if clipped_cells else 'no'}\n"
f"- columns: {', '.join(header)}\n" f"- columns: {', '.join(header)}\n"
f"- dtypes: {', '.join(dtypes)}\n\n" f"- dtypes: {', '.join(dtypes)}\n\n"
f"## Preview (first {min(rows, show_rows)} rows)\n" f"{rows_scope_line}\n"
f"{sample}" f"{sample}"
) )
return _truncate_text(body) return _truncate_text(body)
@ -191,19 +326,7 @@ def _pdf_summary_text(reader: PdfReader) -> str:
def _is_safe_zip_member_name(name: str) -> bool: def _is_safe_zip_member_name(name: str) -> bool:
n = str(name or "").replace("\\", "/").strip() return is_safe_archive_member_name(name)
if not n:
return False
if len(n) > MAX_ZIP_MEMBER_NAME_LENGTH:
return False
if any(ord(ch) < 32 for ch in n):
return False
p = PurePosixPath(n)
if p.is_absolute():
return False
if any(part in ("..", "") for part in p.parts):
return False
return True
def _validate_excel_zip_payload(data: bytes) -> str | None: def _validate_excel_zip_payload(data: bytes) -> str | None:
@ -231,48 +354,21 @@ def _validate_excel_zip_payload(data: bytes) -> str | None:
def process_zip(zip_data: bytes, *, _depth: int = 0) -> list[dict[str, Any]]: def process_zip(zip_data: bytes, *, _depth: int = 0) -> list[dict[str, Any]]:
if _depth > MAX_ZIP_DEPTH: limits = _attachments_limits()
return [ return process_archive(
{ archive_name="bundle.zip",
"type": "text", archive_data=zip_data,
"name": "zip-error", process_member=lambda n, b, d: process_file_data(n, b, _zip_depth=d),
"content": f"ZIP nesting too deep (>{MAX_ZIP_DEPTH})", depth=_depth,
} limits={
] "max_depth": int(limits.get("archive_max_depth") or MAX_ARCHIVE_DEPTH),
attachments: list[dict[str, Any]] = [] "max_file_count": int(limits.get("archive_max_file_count") or MAX_ARCHIVE_FILE_COUNT),
try: "max_entry_bytes": int(limits.get("archive_max_entry_bytes") or MAX_ARCHIVE_ENTRY_BYTES),
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: "max_total_uncompressed_bytes": int(
infos = [x for x in zf.infolist() if not x.is_dir()] limits.get("archive_max_total_uncompressed_bytes") or MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
if len(infos) > MAX_ZIP_FILE_COUNT: ),
raise ValueError(f"ZIP has too many files ({len(infos)} > {MAX_ZIP_FILE_COUNT})") },
total_uncompressed = 0 )
for info in infos:
if not _is_safe_zip_member_name(info.filename):
raise ValueError(f"ZIP contains unsafe path: {info.filename}")
total_uncompressed += int(info.file_size or 0)
if int(info.file_size or 0) > MAX_ZIP_ENTRY_BYTES:
raise ValueError(
f"ZIP entry too large: {info.filename} ({int(info.file_size or 0)} > {MAX_ZIP_ENTRY_BYTES})"
)
if total_uncompressed > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES:
raise ValueError(
"ZIP total uncompressed size too large "
f"({total_uncompressed} > {MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES})"
)
with zf.open(info) as f:
data = f.read()
att = process_file_data(info.filename, data, _zip_depth=_depth + 1)
if att:
attachments.extend(att)
except Exception as e:
attachments.append(
{
"type": "text",
"name": "zip-error",
"content": f"Error parsing zip: {e}",
}
)
return attachments
def process_file_data(name: str, data: bytes, *, _zip_depth: int = 0) -> list[dict[str, Any]]: def process_file_data(name: str, data: bytes, *, _zip_depth: int = 0) -> list[dict[str, Any]]:
@ -336,6 +432,67 @@ def process_file_data(name: str, data: bytes, *, _zip_depth: int = 0) -> list[di
) )
return attachments return attachments
if ext in ("mp4", "mov", "mkv", "webm", "avi", "m4v"):
mime = "video/mp4"
if ext == "webm":
mime = "video/webm"
elif ext == "mov":
mime = "video/quicktime"
elif ext == "mkv":
mime = "video/x-matroska"
elif ext == "avi":
mime = "video/x-msvideo"
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime=mime)
probe_meta: dict[str, Any] = {}
try:
p = AttachmentAssetStore().get_local_path(str(meta.attachment_id))
if p is not None:
probe_meta = _ffprobe_video_meta(p)
except Exception:
probe_meta = {}
attachments.append(
{
"type": "video_ref",
"name": meta.name,
"mime": meta.mime,
"attachment_id": meta.attachment_id,
"bytes": meta.bytes,
**probe_meta,
}
)
attachments.append(
{
"type": "text",
"name": name,
"content": _truncate_text(
"".join(
[
"# Video Attachment\n",
f"- name: {meta.name}\n",
f"- mime: {meta.mime}\n",
f"- bytes: {meta.bytes}\n",
(
f"- duration_sec: {probe_meta.get('duration_sec')}\n"
if probe_meta.get("duration_sec") is not None
else ""
),
(
f"- size: {probe_meta.get('width')}x{probe_meta.get('height')}\n"
if probe_meta.get("width") and probe_meta.get("height")
else ""
),
(f"- fps: {probe_meta.get('fps')}\n" if probe_meta.get("fps") is not None else ""),
"- mode: video_ref\n",
"- query_tool: query_video_attachment\n",
"- note: use `attachment_id` from accompanying `video_ref` attachment.\n",
]
),
limit=MAX_TEXT_CHARS,
),
}
)
return attachments
if ext in ("xlsx", "xls", "csv"): if ext in ("xlsx", "xls", "csv"):
try: try:
if ext == "xlsx": if ext == "xlsx":
@ -453,14 +610,97 @@ def process_file_data(name: str, data: bytes, *, _zip_depth: int = 0) -> list[di
try: try:
doc = Document(io.BytesIO(data)) doc = Document(io.BytesIO(data))
text = "\n".join([p.text for p in doc.paragraphs]) text = "\n".join([p.text for p in doc.paragraphs])
attachments.append({"type": "text", "name": name, "content": _truncate_text(text)}) limits = _attachments_limits()
inline_cap = int(limits.get("text_inline_max_chars") or DEFAULT_TEXT_INLINE_MAX_CHARS)
if len(str(text or "")) <= inline_cap:
attachments.append({"type": "text", "name": name, "content": _truncate_text(text, limit=inline_cap)})
else:
meta = AttachmentAssetStore().save_bytes(
data,
filename=name,
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
text_meta = save_text_document(
attachment_id=str(meta.attachment_id),
name=name,
text=str(text or ""),
source_kind="docx",
chunk_size=int(limits.get("text_chunk_size") or DEFAULT_TEXT_CHUNK_SIZE),
chunk_overlap=int(limits.get("text_chunk_overlap") or DEFAULT_TEXT_CHUNK_OVERLAP),
)
top_k = int(limits.get("text_query_top_k") or DEFAULT_TEXT_QUERY_TOP_K)
attachments.append(
{
"type": "text",
"name": name,
"content": _text_ref_summary(
name=name,
chars=int(text_meta.get("chars") or 0),
chunks=int(text_meta.get("chunks") or 0),
source_kind="docx",
preview=str(text or ""),
top_k=top_k,
),
}
)
attachments.append(
{
"type": "text_ref",
"name": str(name),
"attachment_id": str(meta.attachment_id),
"text_id": str(text_meta.get("text_id") or ""),
"chars": int(text_meta.get("chars") or 0),
"chunks": int(text_meta.get("chunks") or 0),
"source_kind": "docx",
}
)
except Exception as e: except Exception as e:
attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing docx: {e}")}) attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing docx: {e}")})
elif ext == "pdf": elif ext == "pdf":
try: try:
reader = PdfReader(io.BytesIO(data)) reader = PdfReader(io.BytesIO(data))
attachments.append({"type": "text", "name": name, "content": _pdf_summary_text(reader)}) text = _pdf_summary_text(reader)
limits = _attachments_limits()
inline_cap = int(limits.get("text_inline_max_chars") or DEFAULT_TEXT_INLINE_MAX_CHARS)
if len(str(text or "")) <= inline_cap:
attachments.append({"type": "text", "name": name, "content": _truncate_text(text, limit=inline_cap)})
else:
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime="application/pdf")
text_meta = save_text_document(
attachment_id=str(meta.attachment_id),
name=name,
text=str(text or ""),
source_kind="pdf",
chunk_size=int(limits.get("text_chunk_size") or DEFAULT_TEXT_CHUNK_SIZE),
chunk_overlap=int(limits.get("text_chunk_overlap") or DEFAULT_TEXT_CHUNK_OVERLAP),
)
top_k = int(limits.get("text_query_top_k") or DEFAULT_TEXT_QUERY_TOP_K)
attachments.append(
{
"type": "text",
"name": name,
"content": _text_ref_summary(
name=name,
chars=int(text_meta.get("chars") or 0),
chunks=int(text_meta.get("chunks") or 0),
source_kind="pdf",
preview=str(text or ""),
top_k=top_k,
),
}
)
attachments.append(
{
"type": "text_ref",
"name": str(name),
"attachment_id": str(meta.attachment_id),
"text_id": str(text_meta.get("text_id") or ""),
"chars": int(text_meta.get("chars") or 0),
"chunks": int(text_meta.get("chunks") or 0),
"source_kind": "pdf",
}
)
except Exception as e: except Exception as e:
attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing pdf: {e}")}) attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing pdf: {e}")})
@ -468,10 +708,65 @@ def process_file_data(name: str, data: bytes, *, _zip_depth: int = 0) -> list[di
text = _decode_text_bytes(data) text = _decode_text_bytes(data)
if ext == "html": if ext == "html":
text = _clean_html_to_text(text) text = _clean_html_to_text(text)
attachments.append({"type": "text", "name": name, "content": _truncate_text(text)}) limits = _attachments_limits()
inline_cap = int(limits.get("text_inline_max_chars") or DEFAULT_TEXT_INLINE_MAX_CHARS)
if len(str(text or "")) <= inline_cap:
attachments.append({"type": "text", "name": name, "content": _truncate_text(text, limit=inline_cap)})
else:
meta = AttachmentAssetStore().save_bytes(data, filename=name, mime="text/plain")
text_meta = save_text_document(
attachment_id=str(meta.attachment_id),
name=name,
text=str(text or ""),
source_kind=ext or "text",
chunk_size=int(limits.get("text_chunk_size") or DEFAULT_TEXT_CHUNK_SIZE),
chunk_overlap=int(limits.get("text_chunk_overlap") or DEFAULT_TEXT_CHUNK_OVERLAP),
)
top_k = int(limits.get("text_query_top_k") or DEFAULT_TEXT_QUERY_TOP_K)
attachments.append(
{
"type": "text",
"name": name,
"content": _text_ref_summary(
name=name,
chars=int(text_meta.get("chars") or 0),
chunks=int(text_meta.get("chunks") or 0),
source_kind=str(text_meta.get("source_kind") or ext or "text"),
preview=str(text or ""),
top_k=top_k,
),
}
)
attachments.append(
{
"type": "text_ref",
"name": str(name),
"attachment_id": str(meta.attachment_id),
"text_id": str(text_meta.get("text_id") or ""),
"chars": int(text_meta.get("chars") or 0),
"chunks": int(text_meta.get("chunks") or 0),
"source_kind": str(text_meta.get("source_kind") or ext or "text"),
}
)
elif ext == "zip": elif detect_archive_kind(name):
attachments.extend(process_zip(data, _depth=_zip_depth)) limits = _attachments_limits()
attachments.extend(
process_archive(
archive_name=name,
archive_data=data,
process_member=lambda n, b, d: process_file_data(n, b, _zip_depth=d),
depth=_zip_depth,
limits={
"max_depth": int(limits.get("archive_max_depth") or MAX_ARCHIVE_DEPTH),
"max_file_count": int(limits.get("archive_max_file_count") or MAX_ARCHIVE_FILE_COUNT),
"max_entry_bytes": int(limits.get("archive_max_entry_bytes") or MAX_ARCHIVE_ENTRY_BYTES),
"max_total_uncompressed_bytes": int(
limits.get("archive_max_total_uncompressed_bytes") or MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
),
},
)
)
return attachments return attachments

View file

@ -35,6 +35,11 @@ def _meta_path(table_id: str) -> Path:
return _tabular_root() / f"{table_id}.meta.json" return _tabular_root() / f"{table_id}.meta.json"
def _is_valid_table_id(table_id: str) -> bool:
tid = str(table_id or "").strip().lower()
return bool(re.fullmatch(r"[0-9a-f]{64}", tid))
def _normalize_columns(raw_cols: list[Any]) -> tuple[list[str], list[dict[str, Any]]]: def _normalize_columns(raw_cols: list[Any]) -> tuple[list[str], list[dict[str, Any]]]:
out: list[str] = [] out: list[str] = []
mapping: list[dict[str, Any]] = [] mapping: list[dict[str, Any]] = []
@ -311,7 +316,10 @@ def save_workbook(*, attachment_id: str, name: str, sheets: dict[str, pd.DataFra
def get_table_meta(table_id: str) -> dict[str, Any] | None: def get_table_meta(table_id: str) -> dict[str, Any] | None:
p = _meta_path(str(table_id or "").strip()) tid = str(table_id or "").strip()
if not _is_valid_table_id(tid):
return None
p = _meta_path(tid)
if not p.exists(): if not p.exists():
return None return None
try: try:
@ -352,6 +360,29 @@ def _columns_for_sheet(meta: dict[str, Any], table_name: str) -> list[str]:
return [] return []
def _sheet_hint_payload(meta: dict[str, Any], *, selected_sheet: str | None, table_name: str) -> dict[str, Any] | None:
sheets = meta.get("sheets") if isinstance(meta.get("sheets"), list) else []
valid_rows = [x for x in sheets if isinstance(x, dict)]
if len(valid_rows) <= 1:
return None
if str(selected_sheet or "").strip():
return None
names = [str((x or {}).get("sheet_name") or "") for x in valid_rows]
names = [x for x in names if x]
if len(names) <= 1:
return None
default_sheet_name = ""
for row in valid_rows:
if str(row.get("table_name") or "") == str(table_name):
default_sheet_name = str(row.get("sheet_name") or "")
break
return {
"available_sheets": names[:20],
"default_sheet": default_sheet_name,
"note": "Workbook contains multiple sheets; pass `sheet` to target a specific sheet.",
}
def query_table( def query_table(
*, *,
table_id: str, table_id: str,
@ -361,6 +392,12 @@ def query_table(
where_contains: dict[str, str] | None = None, where_contains: dict[str, str] | None = None,
sheet: str | None = None, sheet: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id) meta = get_table_meta(table_id)
if not isinstance(meta, dict): if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"} return {"ok": False, "error": "table_not_found"}
@ -392,6 +429,7 @@ def query_table(
out_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params) out_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params)
with sqlite3.connect(str(db)) as conn: with sqlite3.connect(str(db)) as conn:
total = int(conn.execute(f'SELECT COUNT(1) AS n FROM "{table_name}"').fetchone()[0] or 0) total = int(conn.execute(f'SELECT COUNT(1) AS n FROM "{table_name}"').fetchone()[0] or 0)
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
return { return {
"ok": True, "ok": True,
"table_id": str(table_id), "table_id": str(table_id),
@ -404,6 +442,7 @@ def query_table(
"limit": lim, "limit": lim,
"offset": off, "offset": off,
"engine": engine, "engine": engine,
**({"sheet_hint": hint} if isinstance(hint, dict) else {}),
} }
@ -417,6 +456,12 @@ def aggregate_table(
top_n: int = 20, top_n: int = 20,
sheet: str | None = None, sheet: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id) meta = get_table_meta(table_id)
if not isinstance(meta, dict): if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"} return {"ok": False, "error": "table_not_found"}
@ -465,7 +510,7 @@ def aggregate_table(
"value": float(r.get("metric_value") or 0.0), "value": float(r.get("metric_value") or 0.0),
} }
) )
return { out = {
"ok": True, "ok": True,
"table_id": str(table_id), "table_id": str(table_id),
"sheet": str(sheet or ""), "sheet": str(sheet or ""),
@ -477,10 +522,14 @@ def aggregate_table(
"top_n": lim, "top_n": lim,
"engine": engine, "engine": engine,
} }
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
sql = f'SELECT {metric_sql} AS metric_value FROM "{table_name}"{where_sql} ' sql = f'SELECT {metric_sql} AS metric_value FROM "{table_name}"{where_sql} '
raw_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params) raw_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params)
row = raw_rows[0] if raw_rows else {} row = raw_rows[0] if raw_rows else {}
return { out = {
"ok": True, "ok": True,
"table_id": str(table_id), "table_id": str(table_id),
"sheet": str(sheet or ""), "sheet": str(sheet or ""),
@ -491,6 +540,10 @@ def aggregate_table(
"value": float((row.get("metric_value") if isinstance(row, dict) else 0.0) or 0.0), "value": float((row.get("metric_value") if isinstance(row, dict) else 0.0) or 0.0),
"engine": engine, "engine": engine,
} }
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
def run_table_sql( def run_table_sql(
@ -500,6 +553,12 @@ def run_table_sql(
limit: int = 200, limit: int = 200,
sheet: str | None = None, sheet: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id) meta = get_table_meta(table_id)
if not isinstance(meta, dict): if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"} return {"ok": False, "error": "table_not_found"}
@ -547,7 +606,7 @@ def run_table_sql(
}, },
} }
out_rows = rows[:lim] out_rows = rows[:lim]
return { out = {
"ok": True, "ok": True,
"table_id": str(table_id), "table_id": str(table_id),
"name": str(meta.get("name") or ""), "name": str(meta.get("name") or ""),
@ -568,6 +627,10 @@ def run_table_sql(
"limit": lim, "limit": lim,
"engine": engine, "engine": engine,
} }
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
def analyze_table_full_scan( def analyze_table_full_scan(
@ -577,6 +640,12 @@ def analyze_table_full_scan(
sheet: str | None = None, sheet: str | None = None,
top_values_limit: int = 3, top_values_limit: int = 3,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not _is_valid_table_id(table_id):
return {
"ok": False,
"error": "table_id_invalid_format",
"hint": "Use table_id from tabular_ref attachment (64-char hex id).",
}
meta = get_table_meta(table_id) meta = get_table_meta(table_id)
if not isinstance(meta, dict): if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"} return {"ok": False, "error": "table_not_found"}
@ -628,7 +697,7 @@ def analyze_table_full_scan(
item["top_values"] = [{"value": str(r[0]), "count": int(r[1] or 0)} for r in tops] item["top_values"] = [{"value": str(r[0]), "count": int(r[1] or 0)} for r in tops]
col_stats.append(item) col_stats.append(item)
elapsed_ms = int((time.perf_counter() - started) * 1000) elapsed_ms = int((time.perf_counter() - started) * 1000)
return { out = {
"ok": True, "ok": True,
"table_id": str(table_id), "table_id": str(table_id),
"name": str(meta.get("name") or ""), "name": str(meta.get("name") or ""),
@ -645,6 +714,10 @@ def analyze_table_full_scan(
}, },
"engine": "builtin_sqlite_fullscan", "engine": "builtin_sqlite_fullscan",
} }
hint = _sheet_hint_payload(meta, selected_sheet=sheet, table_name=table_name)
if isinstance(hint, dict):
out["sheet_hint"] = hint
return out
__all__ = [ __all__ = [

View file

@ -0,0 +1,197 @@
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
from oclaw.platform.config.paths import attachments_dir
DEFAULT_TEXT_INLINE_MAX_CHARS = 12_000
DEFAULT_TEXT_CHUNK_SIZE = 1_600
DEFAULT_TEXT_CHUNK_OVERLAP = 200
def _text_root() -> Path:
p = (attachments_dir() / "textual").resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def _text_id(attachment_id: str, name: str) -> str:
raw = f"{attachment_id}:{name}".encode("utf-8", errors="ignore")
return hashlib.sha256(raw).hexdigest()
def _db_path(text_id: str) -> Path:
return _text_root() / f"{text_id}.sqlite"
def _meta_path(text_id: str) -> Path:
return _text_root() / f"{text_id}.meta.json"
def _is_valid_text_id(text_id: str) -> bool:
tid = str(text_id or "").strip().lower()
return bool(re.fullmatch(r"[0-9a-f]{64}", tid))
def _chunk_text(*, text: str, chunk_size: int, overlap: int) -> list[dict[str, Any]]:
body = str(text or "")
if not body:
return []
size = max(200, min(int(chunk_size or DEFAULT_TEXT_CHUNK_SIZE), 8_000))
ov = max(0, min(int(overlap or DEFAULT_TEXT_CHUNK_OVERLAP), size - 1))
chunks: list[dict[str, Any]] = []
i = 0
idx = 0
n = len(body)
while i < n:
end = min(n, i + size)
chunk = body[i:end]
chunks.append({"chunk_index": idx, "start_char": i, "end_char": end, "content": chunk})
if end >= n:
break
i = end - ov
idx += 1
return chunks
def save_text_document(
*,
attachment_id: str,
name: str,
text: str,
source_kind: str,
chunk_size: int = DEFAULT_TEXT_CHUNK_SIZE,
chunk_overlap: int = DEFAULT_TEXT_CHUNK_OVERLAP,
) -> dict[str, Any]:
tid = _text_id(attachment_id=attachment_id, name=name)
db = _db_path(tid)
meta = _meta_path(tid)
chunks = _chunk_text(text=str(text or ""), chunk_size=chunk_size, overlap=chunk_overlap)
with sqlite3.connect(str(db)) as conn:
conn.execute("DROP TABLE IF EXISTS text_chunks")
conn.execute(
(
"CREATE TABLE text_chunks ("
"chunk_index INTEGER NOT NULL, "
"start_char INTEGER NOT NULL, "
"end_char INTEGER NOT NULL, "
"content TEXT NOT NULL)"
)
)
for c in chunks:
conn.execute(
"INSERT INTO text_chunks(chunk_index,start_char,end_char,content) VALUES(?,?,?,?)",
[
int(c.get("chunk_index") or 0),
int(c.get("start_char") or 0),
int(c.get("end_char") or 0),
str(c.get("content") or ""),
],
)
conn.commit()
payload = {
"text_id": tid,
"attachment_id": str(attachment_id or ""),
"name": str(name or ""),
"source_kind": str(source_kind or "text"),
"chars": int(len(str(text or ""))),
"chunks": int(len(chunks)),
"chunk_size": int(chunk_size),
"chunk_overlap": int(chunk_overlap),
"db_path": str(db),
}
meta.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return payload
def get_text_meta(text_id: str) -> dict[str, Any] | None:
tid = str(text_id or "").strip()
if not _is_valid_text_id(tid):
return None
p = _meta_path(tid)
if not p.exists():
return None
try:
obj = json.loads(p.read_text(encoding="utf-8"))
return obj if isinstance(obj, dict) else None
except Exception:
return None
def query_text_document(
*,
text_id: str,
query: str | None = None,
top_k: int = 5,
offset: int = 0,
) -> dict[str, Any]:
tid = str(text_id or "").strip()
if not _is_valid_text_id(tid):
return {"ok": False, "error": "text_id_invalid_format"}
meta = get_text_meta(tid)
if not isinstance(meta, dict):
return {"ok": False, "error": "text_not_found"}
db = Path(str(meta.get("db_path") or ""))
if not db.exists():
return {"ok": False, "error": "text_store_missing"}
lim = max(1, min(int(top_k or 5), 50))
off = max(0, int(offset or 0))
q = str(query or "").strip()
rows: list[dict[str, Any]] = []
with sqlite3.connect(str(db)) as conn:
conn.row_factory = sqlite3.Row
total = int(conn.execute("SELECT COUNT(1) AS n FROM text_chunks").fetchone()[0] or 0)
if q:
q_like = f"%{q.lower()}%"
sql = (
"SELECT chunk_index,start_char,end_char,content, "
"(LENGTH(LOWER(content)) - LENGTH(REPLACE(LOWER(content), LOWER(?), ''))) / MAX(1, LENGTH(?)) AS score "
"FROM text_chunks WHERE LOWER(content) LIKE ? "
"ORDER BY score DESC, chunk_index ASC LIMIT ? OFFSET ?"
)
out = conn.execute(sql, [q, q, q_like, lim, off]).fetchall()
else:
out = conn.execute(
"SELECT chunk_index,start_char,end_char,content, 0 AS score FROM text_chunks ORDER BY chunk_index ASC LIMIT ? OFFSET ?",
[lim, off],
).fetchall()
for r in out:
rows.append(
{
"chunk_index": int(r["chunk_index"] or 0),
"start_char": int(r["start_char"] or 0),
"end_char": int(r["end_char"] or 0),
"content": str(r["content"] or ""),
"score": float(r["score"] or 0.0),
}
)
return {
"ok": True,
"text_id": tid,
"name": str(meta.get("name") or ""),
"source_kind": str(meta.get("source_kind") or "text"),
"chars": int(meta.get("chars") or 0),
"chunks_total": int(meta.get("chunks") or 0),
"rows_total": int(total),
"query": q,
"rows": rows,
"top_k": lim,
"offset": off,
}
__all__ = [
"DEFAULT_TEXT_INLINE_MAX_CHARS",
"DEFAULT_TEXT_CHUNK_SIZE",
"DEFAULT_TEXT_CHUNK_OVERLAP",
"save_text_document",
"get_text_meta",
"query_text_document",
]

View file

@ -177,6 +177,9 @@ def _summarize_unpaired_tool_content(raw: str, *, cap: int) -> str:
def _truncate_tool_context(text: str, *, lang: str) -> str: def _truncate_tool_context(text: str, *, lang: str) -> str:
# NOTE: this is a history-replay safety clamp only.
# Never apply this to current-turn tool rows; otherwise the model may
# incorrectly infer runtime truncation from UI-facing copy.
s = str(text or "").strip() s = str(text or "").strip()
if not s: if not s:
return s return s
@ -315,6 +318,39 @@ def build_llm_messages(
), ),
} }
) )
elif att_type == "text_ref":
name = str(att.get("name") or "document")
text_id = str(att.get("text_id") or "")
chars = int(att.get("chars") or 0)
chunks = int(att.get("chunks") or 0)
source_kind = str(att.get("source_kind") or "text")
aid = str(att.get("attachment_id") or "")
content_list.append(
{
"type": "text",
"text": (
f"[LongTextAttachment]\n"
f"- name: {name}\n"
f"- text_id: {text_id}\n"
f"- attachment_id: {aid}\n"
f"- source_kind: {source_kind}\n"
f"- chars: {chars}\n"
f"- chunks: {chunks}\n"
f"- tools: query_text_attachment\n"
"- note: for detailed evidence, call `query_text_attachment` with text_id."
),
}
)
elif att_type == "video_ref":
name = str(att.get("name") or "video")
mime = str(att.get("mime") or "video/*")
aid = str(att.get("attachment_id") or "")
sz = att.get("bytes")
meta_line = f"[VideoAttachment]\n- name: {name}\n- mime: {mime}\n- attachment_id: {aid}"
if sz:
meta_line += f"\n- bytes: {sz}"
meta_line += "\n- tools: query_video_attachment"
content_list.append({"type": "text", "text": meta_line})
elif att_type == "relay_pointer": elif att_type == "relay_pointer":
p_uri = str(att.get("pointer_uri") or "").strip() p_uri = str(att.get("pointer_uri") or "").strip()
if not p_uri: if not p_uri:
@ -471,7 +507,7 @@ def build_llm_messages(
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>" tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
except Exception: except Exception:
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>" tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
if tool_context_truncate_enabled: if tool_context_truncate_enabled and str(tool_call_id) in historical_tool_ids:
# Preserve explicit guard markers from upstream context guards. # Preserve explicit guard markers from upstream context guards.
if "_tool_result_guarded" not in str(tool_content_out or ""): if "_tool_result_guarded" not in str(tool_content_out or ""):
tool_content_out = _truncate_tool_context(tool_content_out, lang=lang) tool_content_out = _truncate_tool_context(tool_content_out, lang=lang)
@ -494,8 +530,7 @@ def build_llm_messages(
r = getattr(m, "content", "") or "" r = getattr(m, "content", "") or ""
cap2 = tool_llm_message_max_chars() cap2 = tool_llm_message_max_chars()
pretty2 = _summarize_unpaired_tool_content(r, cap=cap2) pretty2 = _summarize_unpaired_tool_content(r, cap=cap2)
if tool_context_truncate_enabled: # Unpaired tool rows are already summarized; avoid extra 50-char clipping.
pretty2 = _truncate_tool_context(pretty2, lang=lang)
out.append( out.append(
{ {
"role": "assistant", "role": "assistant",

View file

@ -11,6 +11,7 @@ import logging
import time import time
import os import os
import threading import threading
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import TimeoutError as FuturesTimeoutError from concurrent.futures import TimeoutError as FuturesTimeoutError
from dataclasses import dataclass from dataclasses import dataclass
@ -20,7 +21,10 @@ from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.base import ToolRegistry from oclaw.runtime.tools.base import ToolRegistry
from oclaw.platform.llm.chat_models import LLMToolCall from oclaw.platform.llm.chat_models import LLMToolCall
from oclaw.runtime.tools.tool_validation import validate_tool_arguments from oclaw.runtime.tools.tool_validation import validate_tool_arguments
from oclaw.runtime.tools.experts.workspace.workspace_base import workspace_path_access_scope from oclaw.runtime.tools.experts.workspace.workspace_base import (
workspace_path_access_scope,
workspace_write_namespace_scope,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_tool_exec_log = logging.getLogger("oclaw.tool_exec") _tool_exec_log = logging.getLogger("oclaw.tool_exec")
@ -33,6 +37,170 @@ _SQL_REPLAY_COMPACT_TOOL_NAMES = {
"run_tabular_sql", "run_tabular_sql",
"analyze_tabular_attachment_full_scan", "analyze_tabular_attachment_full_scan",
} }
_TABULAR_QUERY_TOOL_NAMES = {
"query_tabular_attachment",
"run_tabular_sql",
"analyze_tabular_attachment_full_scan",
}
_TEXT_QUERY_TOOL_NAMES = {
"query_text_attachment",
}
_IMAGE_QUERY_TOOL_NAMES = {
"query_image_attachment",
}
_VIDEO_QUERY_TOOL_NAMES = {
"query_video_attachment",
}
def _message_has_tabular_ref(raw_attachments: Any) -> bool:
if raw_attachments is None:
return False
obj = raw_attachments
if isinstance(raw_attachments, str):
s = str(raw_attachments or "").strip()
if not s:
return False
try:
obj = json.loads(s)
except Exception:
return False
if isinstance(obj, dict):
items = [obj]
elif isinstance(obj, list):
items = obj
else:
return False
for it in items:
if not isinstance(it, dict):
continue
if str(it.get("type") or "").strip().lower() == "tabular_ref":
return True
return False
def _message_has_text_ref(raw_attachments: Any) -> bool:
if raw_attachments is None:
return False
obj = raw_attachments
if isinstance(raw_attachments, str):
s = str(raw_attachments or "").strip()
if not s:
return False
try:
obj = json.loads(s)
except Exception:
return False
if isinstance(obj, dict):
items = [obj]
elif isinstance(obj, list):
items = obj
else:
return False
for it in items:
if not isinstance(it, dict):
continue
if str(it.get("type") or "").strip().lower() == "text_ref":
return True
return False
def _message_has_image_ref(raw_attachments: Any) -> bool:
if raw_attachments is None:
return False
obj = raw_attachments
if isinstance(raw_attachments, str):
s = str(raw_attachments or "").strip()
if not s:
return False
try:
obj = json.loads(s)
except Exception:
return False
if isinstance(obj, dict):
items = [obj]
elif isinstance(obj, list):
items = obj
else:
return False
for it in items:
if not isinstance(it, dict):
continue
t = str(it.get("type") or "").strip().lower()
if t in {"image_ref", "image", "input_image"}:
return True
return False
def _message_has_video_ref(raw_attachments: Any) -> bool:
if raw_attachments is None:
return False
obj = raw_attachments
if isinstance(raw_attachments, str):
s = str(raw_attachments or "").strip()
if not s:
return False
try:
obj = json.loads(s)
except Exception:
return False
if isinstance(obj, dict):
items = [obj]
elif isinstance(obj, list):
items = obj
else:
return False
for it in items:
if not isinstance(it, dict):
continue
t = str(it.get("type") or "").strip().lower()
if t == "video_ref":
return True
return False
def _session_has_tabular_ref(store: Any, session_id: str, *, limit: int = 300) -> bool:
try:
rows = store.get_messages(session_id=session_id, limit=max(1, int(limit)))
except Exception:
return False
for m in rows or []:
if _message_has_tabular_ref(getattr(m, "attachments", None)):
return True
return False
def _session_has_text_ref(store: Any, session_id: str, *, limit: int = 300) -> bool:
try:
rows = store.get_messages(session_id=session_id, limit=max(1, int(limit)))
except Exception:
return False
for m in rows or []:
if _message_has_text_ref(getattr(m, "attachments", None)):
return True
return False
def _session_has_image_ref(store: Any, session_id: str, *, limit: int = 300) -> bool:
try:
rows = store.get_messages(session_id=session_id, limit=max(1, int(limit)))
except Exception:
return False
for m in rows or []:
if _message_has_image_ref(getattr(m, "attachments", None)):
return True
return False
def _session_has_video_ref(store: Any, session_id: str, *, limit: int = 300) -> bool:
try:
rows = store.get_messages(session_id=session_id, limit=max(1, int(limit)))
except Exception:
return False
for m in rows or []:
if _message_has_video_ref(getattr(m, "attachments", None)):
return True
return False
def normalize_tool_result(result: Any) -> dict[str, Any]: def normalize_tool_result(result: Any) -> dict[str, Any]:
@ -206,6 +374,7 @@ class ToolExecutionContext:
#: If ``get_ui_session_owner`` fails, load allowlist for this (tenant, user) from the HTTP/gateway request (``metadata``). #: If ``get_ui_session_owner`` fails, load allowlist for this (tenant, user) from the HTTP/gateway request (``metadata``).
path_policy_tenant_id: str | None = None path_policy_tenant_id: str | None = None
path_policy_user_id: str | None = None path_policy_user_id: str | None = None
workspace_dir: str | None = None
turn_uuid: str | None = None turn_uuid: str | None = None
@ -235,13 +404,21 @@ class ToolExecutor:
timeout_s = 30.0 timeout_s = 30.0
def _call() -> Any: def _call() -> Any:
ws_ns = ""
raw_ws = str(ctx.workspace_dir or "").strip()
if raw_ws:
try:
wp = Path(raw_ws)
ws_ns = str(wp.name or wp.stem or "").strip()
except Exception:
ws_ns = ""
with workspace_path_access_scope( with workspace_path_access_scope(
ctx.store, ctx.store,
ctx.session_id, ctx.session_id,
owner_fallback_session_id=ctx.workspace_owner_session_id, owner_fallback_session_id=ctx.workspace_owner_session_id,
allowlist_tenant_id=ctx.path_policy_tenant_id, allowlist_tenant_id=ctx.path_policy_tenant_id,
allowlist_user_id=ctx.path_policy_user_id, allowlist_user_id=ctx.path_policy_user_id,
): ), workspace_write_namespace_scope(ws_ns):
return tool.handler(tc.arguments) return tool.handler(tc.arguments)
if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0: if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0:
@ -400,65 +577,98 @@ class ToolExecutor:
) )
return counts, observed_rows return counts, observed_rows
def _compact_tool_result_for_history(
*,
tool_name: str,
result: dict[str, Any],
call_index: int,
threshold: int,
observed_rows_this_call: int,
observed_rows_cumulative_in_turn: int,
) -> dict[str, Any]:
out: dict[str, Any] = {
"ok": bool(result.get("ok")),
"_history_compacted": True,
"_history_compact_reason": "repeated_tool_calls_in_turn",
"tool_name": str(tool_name or ""),
"call_index_in_turn_for_tool": int(call_index),
"compact_threshold": int(threshold),
"_tool_observed_rows_this_call": int(observed_rows_this_call),
"_tool_observed_rows_cumulative_in_turn": int(observed_rows_cumulative_in_turn),
"result_keys": sorted(list(result.keys()))[:30],
"result_bytes": int(_json_blob_size(result)),
"hint": (
"Repeated tool calls in this turn were compacted in chat history to avoid context bloat. "
"Full payload remains in tool logs."
),
"audit_note": (
"History is compacted by system optimization. If more detail is needed, continue querying "
"with the same SQL/tool parameters from this turn."
),
}
for key in ("error_code", "error", "rows_returned", "limit", "table_id", "engine"):
if key in result:
out[key] = result.get(key)
for key in ("input_sql", "executed_sql"):
v = str(result.get(key) or "").strip()
if v:
out[key] = v[:1200]
guard = result.get("sql_guard")
if isinstance(guard, dict):
out["sql_guard"] = {
"readonly_enforced": bool(guard.get("readonly_enforced")),
"auto_limit_applied": bool(guard.get("auto_limit_applied")),
"result_row_cap": int(guard.get("result_row_cap") or 0),
}
return out
_check_stop() _check_stop()
if not tool_uses: if not tool_uses:
return [], {} return [], {}
history_summary_threshold = int(tool_history_summary_after_calls())
turn_tool_name_counts, turn_tool_observed_rows = _load_turn_tool_stats() turn_tool_name_counts, turn_tool_observed_rows = _load_turn_tool_stats()
local_turn_tool_name_counts: dict[str, int] = {} local_turn_tool_name_counts: dict[str, int] = {}
local_turn_tool_observed_rows: dict[str, int] = {} local_turn_tool_observed_rows: dict[str, int] = {}
local_turn_written_tool_msgs: dict[str, list[dict[str, Any]]] = {} has_tabular_ref_in_session = _session_has_tabular_ref(ctx.store, ctx.session_id)
has_text_ref_in_session = _session_has_text_ref(ctx.store, ctx.session_id)
has_image_ref_in_session = _session_has_image_ref(ctx.store, ctx.session_id)
has_video_ref_in_session = _session_has_video_ref(ctx.store, ctx.session_id)
results_by_id: dict[str, tuple[dict[str, Any], int]] = {} results_by_id: dict[str, tuple[dict[str, Any], int]] = {}
runnable_tool_uses: list[LLMToolCall] = [] runnable_tool_uses: list[LLMToolCall] = []
sig_seen: dict[str, int] = {} sig_seen: dict[str, int] = {}
budget = max(1, min(int(signature_budget or 2), 8)) budget = max(1, min(int(signature_budget or 2), 8))
for tc in tool_uses: for tc in tool_uses:
if tc.name in _TABULAR_QUERY_TOOL_NAMES and not has_tabular_ref_in_session:
results_by_id[tc.id] = (
{
"ok": False,
"error_code": "tabular_ref_missing",
"error": "tabular_ref_missing",
"hint": "No tabular_ref attachment found in this session. Query tools require table_id from tabular_ref.",
},
0,
)
_trace(
"tabular_query_guard",
{
"tool_name": tc.name,
"blocked": True,
"reason": "tabular_ref_missing",
},
)
continue
if tc.name in _TEXT_QUERY_TOOL_NAMES and not has_text_ref_in_session:
results_by_id[tc.id] = (
{
"ok": False,
"error_code": "text_ref_missing",
"error": "text_ref_missing",
"hint": "No text_ref attachment found in this session. Query tools require text_id from text_ref.",
},
0,
)
_trace(
"text_query_guard",
{
"tool_name": tc.name,
"blocked": True,
"reason": "text_ref_missing",
},
)
continue
if tc.name in _IMAGE_QUERY_TOOL_NAMES and not has_image_ref_in_session:
results_by_id[tc.id] = (
{
"ok": False,
"error_code": "image_ref_missing",
"error": "image_ref_missing",
"hint": "No image_ref attachment found in this session. Query tools require attachment_id from image_ref.",
},
0,
)
_trace(
"image_query_guard",
{
"tool_name": tc.name,
"blocked": True,
"reason": "image_ref_missing",
},
)
continue
if tc.name in _VIDEO_QUERY_TOOL_NAMES and not has_video_ref_in_session:
results_by_id[tc.id] = (
{
"ok": False,
"error_code": "video_ref_missing",
"error": "video_ref_missing",
"hint": "No video_ref attachment found in this session. Query tools require attachment_id from video_ref.",
},
0,
)
_trace(
"video_query_guard",
{
"tool_name": tc.name,
"blocked": True,
"reason": "video_ref_missing",
},
)
continue
sig = f"{tc.name}:{self._json_dumps_safe(dict(tc.arguments or {}))}" sig = f"{tc.name}:{self._json_dumps_safe(dict(tc.arguments or {}))}"
count = int(sig_seen.get(sig, 0)) count = int(sig_seen.get(sig, 0))
if count >= budget: if count >= budget:
@ -539,27 +749,14 @@ class ToolExecutor:
duration_ms=duration_ms, duration_ms=duration_ms,
) )
tool_log_write_ms = int((time.perf_counter() - t_db1) * 1000) tool_log_write_ms = int((time.perf_counter() - t_db1) * 1000)
# Full payload stays in tool_log; chat history must stay under provider per-message limits. # Keep full payload during the active turn. History compaction is deferred
# until the turn finishes, so current-round model context remains lossless.
t_trunc = time.perf_counter() t_trunc = time.perf_counter()
observed_rows_this_call = int(_estimate_observed_rows(result)) observed_rows_this_call = int(_estimate_observed_rows(result))
result_for_llm = truncate_tool_result_for_llm_messages(result) result_for_llm = dict(result or {})
should_compact_history = tc.name in _SQL_REPLAY_COMPACT_TOOL_NAMES if tc.name in _SQL_REPLAY_COMPACT_TOOL_NAMES:
if history_summary_threshold > 0 and should_compact_history:
prior = int(turn_tool_name_counts.get(tc.name, 0))
current = int(local_turn_tool_name_counts.get(tc.name, 0)) current = int(local_turn_tool_name_counts.get(tc.name, 0))
call_index = prior + current + 1
prior_rows = int(turn_tool_observed_rows.get(tc.name, 0))
current_rows = int(local_turn_tool_observed_rows.get(tc.name, 0)) current_rows = int(local_turn_tool_observed_rows.get(tc.name, 0))
observed_rows_cumulative_in_turn = prior_rows + current_rows + observed_rows_this_call
if call_index >= history_summary_threshold:
result_for_llm = _compact_tool_result_for_history(
tool_name=tc.name,
result=result,
call_index=call_index,
threshold=history_summary_threshold,
observed_rows_this_call=observed_rows_this_call,
observed_rows_cumulative_in_turn=observed_rows_cumulative_in_turn,
)
local_turn_tool_name_counts[tc.name] = current + 1 local_turn_tool_name_counts[tc.name] = current + 1
local_turn_tool_observed_rows[tc.name] = current_rows + observed_rows_this_call local_turn_tool_observed_rows[tc.name] = current_rows + observed_rows_this_call
trunc_ms = int((time.perf_counter() - t_trunc) * 1000) trunc_ms = int((time.perf_counter() - t_trunc) * 1000)
@ -576,47 +773,6 @@ class ToolExecutor:
) )
tool_msg_write_ms = int((time.perf_counter() - t_db2) * 1000) tool_msg_write_ms = int((time.perf_counter() - t_db2) * 1000)
tool_messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_content, "name": tc.name}) tool_messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_content, "name": tc.name})
tool_messages_idx = len(tool_messages) - 1
call_index_for_tool = int(turn_tool_name_counts.get(tc.name, 0)) + int(local_turn_tool_name_counts.get(tc.name, 0))
local_turn_written_tool_msgs.setdefault(tc.name, []).append(
{
"message_id": int(getattr(msg_row, "id", 0) or 0),
"tool_messages_idx": int(tool_messages_idx),
"result": dict(result or {}),
"observed_rows": int(observed_rows_this_call),
"call_index": int(call_index_for_tool),
"compacted": bool(isinstance(result_for_llm, dict) and result_for_llm.get("_history_compacted")),
}
)
# When threshold is reached for one SQL tool in the turn, retro-compact earlier same-tool tool messages too.
if history_summary_threshold > 0 and should_compact_history and call_index_for_tool >= history_summary_threshold:
running_rows = int(turn_tool_observed_rows.get(tc.name, 0))
entries = list(local_turn_written_tool_msgs.get(tc.name) or [])
for ent in entries:
running_rows += int(ent.get("observed_rows") or 0)
compacted_payload = _compact_tool_result_for_history(
tool_name=tc.name,
result=dict(ent.get("result") or {}),
call_index=int(ent.get("call_index") or 0),
threshold=history_summary_threshold,
observed_rows_this_call=int(ent.get("observed_rows") or 0),
observed_rows_cumulative_in_turn=int(running_rows),
)
compacted_content = self._json_dumps_safe(compacted_payload)
if not bool(ent.get("compacted")):
try:
ctx.store.update_message_content(
session_id=ctx.session_id,
message_id=int(ent.get("message_id") or 0),
content=compacted_content,
event_payload={"tool_name": tc.name, "observed_rows": int(ent.get("observed_rows") or 0)},
)
except Exception:
pass
ent["compacted"] = True
ti = int(ent.get("tool_messages_idx") or -1)
if 0 <= ti < len(tool_messages):
tool_messages[ti]["content"] = compacted_content
_trace( _trace(
"tool_result", "tool_result",
{ {
@ -650,6 +806,57 @@ class ToolExecutor:
on_tool_ui("tool_use_result", payload) on_tool_ui("tool_use_result", payload)
return tool_messages, results_by_id return tool_messages, results_by_id
def compact_turn_tool_messages_for_storage(
*,
store: Any,
session_id: str,
turn_uuid: str | None,
) -> dict[str, int]:
"""Compact persisted tool messages after turn completion.
This intentionally runs *after* the active turn so current-round model
context is not affected by truncation/compaction.
"""
tid = str(turn_uuid or "").strip()
if not tid:
return {"scanned": 0, "updated": 0}
try:
rows = store.get_messages(session_id=session_id, limit=800)
except Exception:
return {"scanned": 0, "updated": 0}
scanned = 0
updated = 0
for m in rows or []:
if str(getattr(m, "role", "") or "") != "tool":
continue
if str(getattr(m, "turn_uuid", "") or "") != tid:
continue
scanned += 1
raw = str(getattr(m, "content", "") or "")
if not raw.strip():
continue
try:
obj = json.loads(raw)
except Exception:
continue
if not isinstance(obj, dict):
continue
compacted = truncate_tool_result_for_llm_messages(obj)
if compacted == obj:
continue
try:
store.update_message_content(
session_id=session_id,
message_id=int(getattr(m, "id", 0) or 0),
content=json.dumps(compacted, ensure_ascii=False, default=str),
event_payload=getattr(m, "event_payload", None),
)
updated += 1
except Exception:
continue
return {"scanned": int(scanned), "updated": int(updated)}
__all__ = [ __all__ = [
"ToolExecutionConfig", "ToolExecutionConfig",
"ToolExecutionContext", "ToolExecutionContext",
@ -658,4 +865,5 @@ __all__ = [
"partition_tool_use_batches", "partition_tool_use_batches",
"tool_llm_message_max_chars", "tool_llm_message_max_chars",
"truncate_tool_result_for_llm_messages", "truncate_tool_result_for_llm_messages",
"compact_turn_tool_messages_for_storage",
] ]

View file

@ -8,6 +8,7 @@ import uuid
import copy import copy
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@ -24,6 +25,8 @@ from oclaw.runtime.tools.base import ToolRegistry
from oclaw.runtime.hooks_runtime import trigger_hook_event from oclaw.runtime.hooks_runtime import trigger_hook_event
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000 _OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
_OCLAW_ATTACHMENT_TEXT_REPLAY_CAP_CHARS = 4_000
_OCLAW_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS = 4_000
_DIRECT_LOOP_OC_STAGE: dict[str, str] = { _DIRECT_LOOP_OC_STAGE: dict[str, str] = {
"tool_wire_filter": "wire_filter", "tool_wire_filter": "wire_filter",
@ -39,6 +42,82 @@ _TOOL_WIRE_LAST_WARM_ROLES: tuple[str, ...] = ()
_TOOL_WIRE_LAST_WARM_COUNT: int = 0 _TOOL_WIRE_LAST_WARM_COUNT: int = 0
def _safe_int(raw: Any, default: int, *, min_value: int = 1, max_value: int = 2_000_000) -> int:
try:
value = int(raw)
except Exception:
return default
if value < min_value:
return default
return min(value, max_value)
def _oclaw_config_path() -> Path:
raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
if raw:
p = Path(raw)
return p if p.is_absolute() else p.resolve()
return Path(__file__).resolve().parents[1] / "oclaw.json"
def _image_tool_result_replay_cap_chars(store: Any) -> int:
default = _OCLAW_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS
raw_setting = ""
try:
raw_setting = str(store.get_setting("AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS") or "").strip()
except Exception:
raw_setting = ""
if raw_setting:
return _safe_int(raw_setting, default, min_value=600, max_value=30_000)
raw_env = str(os.getenv("AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS") or "").strip()
if raw_env:
return _safe_int(raw_env, default, min_value=600, max_value=30_000)
try:
cfg_path = _oclaw_config_path()
if cfg_path.exists() and cfg_path.is_file():
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
tab = (
(((obj.get("plugins") or {}).get("entries") or {}).get("memory-wiki") or {})
.get("auto", {})
.get("attachments", {})
.get("tabular", {})
)
if isinstance(tab, dict):
return _safe_int(tab.get("image_result_replay_cap_chars"), default, min_value=600, max_value=30_000)
except Exception:
pass
return default
def _video_tool_result_replay_cap_chars(store: Any) -> int:
default = 4_000
raw_setting = ""
try:
raw_setting = str(store.get_setting("AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS") or "").strip()
except Exception:
raw_setting = ""
if raw_setting:
return _safe_int(raw_setting, default, min_value=600, max_value=30_000)
raw_env = str(os.getenv("AIA_VIDEO_TOOL_RESULT_REPLAY_CAP_CHARS") or "").strip()
if raw_env:
return _safe_int(raw_env, default, min_value=600, max_value=30_000)
try:
cfg_path = _oclaw_config_path()
if cfg_path.exists() and cfg_path.is_file():
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
tab = (
(((obj.get("plugins") or {}).get("entries") or {}).get("memory-wiki") or {})
.get("auto", {})
.get("attachments", {})
.get("tabular", {})
)
if isinstance(tab, dict):
return _safe_int(tab.get("video_result_replay_cap_chars"), default, min_value=600, max_value=30_000)
except Exception:
pass
return default
def _tool_wire_freeze_enabled(store: Any) -> bool: def _tool_wire_freeze_enabled(store: Any) -> bool:
raw = "" raw = ""
try: try:
@ -218,6 +297,7 @@ def _guard_tool_results_for_llm_context(
run_id: str | None = None, run_id: str | None = None,
attempt_no: int | None = None, attempt_no: int | None = None,
lang: str = "", lang: str = "",
active_turn_uuid: str | None = None,
) -> list[Any]: ) -> list[Any]:
"""Hard-guard overlarge `role=tool` message contents before sending to model. """Hard-guard overlarge `role=tool` message contents before sending to model.
@ -225,28 +305,87 @@ def _guard_tool_results_for_llm_context(
in-flight LLM context to prevent provider context overflow spirals. in-flight LLM context to prevent provider context overflow spirals.
""" """
cap = max(4096, min(int(hard_cap_chars or _OCLAW_TOOL_RESULT_HARD_CAP_CHARS), 500_000)) cap = max(4096, min(int(hard_cap_chars or _OCLAW_TOOL_RESULT_HARD_CAP_CHARS), 500_000))
image_cap = _image_tool_result_replay_cap_chars(store)
video_cap = _video_tool_result_replay_cap_chars(store)
out: list[Any] = [] out: list[Any] = []
for m in store_messages or []: for m in store_messages or []:
role = str(getattr(m, "role", "") or "") role = str(getattr(m, "role", "") or "")
if role != "tool": if role != "tool":
out.append(m) out.append(m)
continue continue
raw = str(getattr(m, "content", "") or "") if str(getattr(m, "turn_uuid", "") or "") == str(active_turn_uuid or "") and str(active_turn_uuid or "").strip():
if len(raw) <= cap:
out.append(m) out.append(m)
continue continue
# Best-effort parse tool JSON for a minimal summary. raw = str(getattr(m, "content", "") or "")
# Best-effort parse tool JSON for image-query specific guard and overflow metadata.
ok = None ok = None
error_code = "" error_code = ""
error = "" error = ""
obj: dict[str, Any] | None = None
try: try:
obj = json.loads(raw) parsed = json.loads(raw)
if isinstance(obj, dict): if isinstance(parsed, dict):
obj = parsed
ok = obj.get("ok") ok = obj.get("ok")
error_code = str(obj.get("error_code") or "").strip() error_code = str(obj.get("error_code") or "").strip()
error = str(obj.get("error") or "").strip() error = str(obj.get("error") or "").strip()
except Exception: except Exception:
obj = None obj = None
if isinstance(obj, dict):
task = str(obj.get("task") or "").strip().lower()
text = str(obj.get("text") or "")
has_attachment_id = bool(str(obj.get("attachment_id") or "").strip())
# Guard image describe/OCR result replay aggressively to avoid long visual transcripts
# occupying context across future rounds.
if task in {"describe", "ocr"} and has_attachment_id and len(text) > image_cap:
preview = text[:image_cap] + "\n...<image_tool_result_truncated_for_context_replay>"
guarded_obj = dict(obj)
guarded_obj["text"] = preview
guarded_obj["_image_tool_result_guarded"] = True
guarded_obj["image_result_original_chars"] = len(text)
guarded_obj["image_result_replay_cap_chars"] = image_cap
guarded_obj["image_result_hint"] = (
"Image analysis result was truncated for context replay. "
"Refine query_image_attachment(question=...) for narrower evidence. / "
"图片分析结果在上下文回放中已截断,请缩小 query_image_attachment 的问题范围。"
)
guarded = _json_dumps_safe(guarded_obj)
out.append(
SimpleNamespace(
id=getattr(m, "id", 0),
session_id=getattr(m, "session_id", session_id),
role="tool",
content=guarded,
tool_calls=getattr(m, "tool_calls", None),
timestamp=getattr(m, "timestamp", ""),
attachments=getattr(m, "attachments", None),
)
)
continue
# Guard video transcript replay similarly (usually long).
if str(obj.get("task") or "").strip().lower() == "transcript" and has_attachment_id and len(text) > video_cap:
preview = text[:video_cap] + "\n...<video_tool_result_truncated_for_context_replay>"
guarded_obj = dict(obj)
guarded_obj["text"] = preview
guarded_obj["_video_tool_result_guarded"] = True
guarded_obj["video_result_original_chars"] = len(text)
guarded_obj["video_result_replay_cap_chars"] = video_cap
guarded = _json_dumps_safe(guarded_obj)
out.append(
SimpleNamespace(
id=getattr(m, "id", 0),
session_id=getattr(m, "session_id", session_id),
role="tool",
content=guarded,
tool_calls=getattr(m, "tool_calls", None),
timestamp=getattr(m, "timestamp", ""),
attachments=getattr(m, "attachments", None),
)
)
continue
if len(raw) <= cap:
out.append(m)
continue
preview = raw[: max(1, min(4000, cap - 400))] + "\n...<tool_result_guard_truncated>" preview = raw[: max(1, min(4000, cap - 400))] + "\n...<tool_result_guard_truncated>"
guarded_obj = { guarded_obj = {
"ok": bool(ok) if ok is not None else None, "ok": bool(ok) if ok is not None else None,
@ -294,6 +433,118 @@ def _guard_tool_results_for_llm_context(
return out return out
def _guard_text_attachments_for_llm_context(
*,
store_messages: list[Any],
cap_chars: int,
active_turn_uuid: str | None = None,
) -> list[Any]:
"""Guard overlarge user text attachments for model context replay.
This does NOT rewrite DB history. It only guards the in-flight LLM context to
prevent large attachments from overwhelming context windows.
"""
cap = max(800, min(int(cap_chars or _OCLAW_ATTACHMENT_TEXT_REPLAY_CAP_CHARS), 80_000))
out: list[Any] = []
for m in store_messages or []:
role = str(getattr(m, "role", "") or "")
if role != "user":
out.append(m)
continue
# Never guard the active user turn.
if str(getattr(m, "turn_uuid", "") or "") == str(active_turn_uuid or "") and str(active_turn_uuid or "").strip():
out.append(m)
continue
raw_att = getattr(m, "attachments", None)
if not raw_att:
out.append(m)
continue
try:
att_obj = json.loads(raw_att) if isinstance(raw_att, str) else raw_att
except Exception:
out.append(m)
continue
if isinstance(att_obj, dict):
atts = [att_obj]
elif isinstance(att_obj, list):
atts = att_obj
else:
out.append(m)
continue
has_text_ref = any(
isinstance(a, dict) and str(a.get("type") or "").strip().lower() == "text_ref" for a in atts
)
changed = False
next_atts: list[dict[str, Any]] = []
for a in atts:
if not isinstance(a, dict):
continue
if str(a.get("type") or "").strip().lower() != "text":
next_atts.append(a)
continue
content = str(a.get("content") or "")
# If this user message already has a text_ref, keep inline text very small in replay context.
# The model can retrieve evidence via query_text_attachment(text_id=...).
if has_text_ref and content:
changed = True
name = str(a.get("name") or "attachment")
next_atts.append(
{
**a,
"content": (
"# Attachment (collapsed; text_ref available)\n"
f"- name: {name}\n"
"- note: use `query_text_attachment` with `text_id` from `text_ref` for details.\n"
"...<attachment_collapsed_for_context_replay>"
),
"_attachment_context_guarded": True,
"_attachment_context_collapsed": True,
}
)
continue
if len(content) <= cap:
next_atts.append(a)
continue
changed = True
name = str(a.get("name") or "attachment")
hint_lines = [
"# Attachment (summarized for context replay)",
f"- name: {name}",
f"- original_chars: {len(content)}",
f"- replay_cap_chars: {cap}",
]
if has_text_ref:
hint_lines.append("- note: use `query_text_attachment` with `text_id` from `text_ref` for details.")
else:
hint_lines.append("- note: attachment was large; re-upload or provide a smaller excerpt if needed.")
preview = content[: min(1200, cap)]
next_atts.append(
{
**a,
"content": "\n".join(hint_lines) + "\n\n## Preview\n" + preview + "\n\n...<attachment_truncated_for_context_replay>",
"_attachment_context_guarded": True,
}
)
if not changed:
out.append(m)
continue
out.append(
SimpleNamespace(
id=getattr(m, "id", 0),
session_id=getattr(m, "session_id", ""),
role="user",
content=getattr(m, "content", ""),
tool_calls=getattr(m, "tool_calls", None),
timestamp=getattr(m, "timestamp", ""),
attachments=next_atts,
turn_uuid=getattr(m, "turn_uuid", ""),
event_type=getattr(m, "event_type", ""),
event_payload=getattr(m, "event_payload", None),
)
)
return out
def _check_stop(should_stop: Optional[Callable[[], bool]]) -> None: def _check_stop(should_stop: Optional[Callable[[], bool]]) -> None:
if should_stop and should_stop(): if should_stop and should_stop():
raise RuntimeError("generation interrupted by user") raise RuntimeError("generation interrupted by user")
@ -318,6 +569,7 @@ def _build_model_context(
skill_binding_role: str | None = None, skill_binding_role: str | None = None,
user_text: str = "", user_text: str = "",
prompt_build_context: dict[str, Any] | None = None, prompt_build_context: dict[str, Any] | None = None,
active_turn_uuid: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows = store.get_messages(session_id=session_id, limit=int(max_messages)) rows = store.get_messages(session_id=session_id, limit=int(max_messages))
rows = _guard_tool_results_for_llm_context( rows = _guard_tool_results_for_llm_context(
@ -330,6 +582,12 @@ def _build_model_context(
run_id=run_id, run_id=run_id,
attempt_no=attempt_no, attempt_no=attempt_no,
lang=lang, lang=lang,
active_turn_uuid=active_turn_uuid,
)
rows = _guard_text_attachments_for_llm_context(
store_messages=rows,
cap_chars=_OCLAW_ATTACHMENT_TEXT_REPLAY_CAP_CHARS,
active_turn_uuid=active_turn_uuid,
) )
final_system = build_oclaw_executor_system_prompt( final_system = build_oclaw_executor_system_prompt(
store=store, store=store,
@ -672,10 +930,11 @@ def run_oclaw_direct_loop(
skill_binding_role: str | None = None, skill_binding_role: str | None = None,
wire_policy_role: str | None = None, wire_policy_role: str | None = None,
prompt_build_context: dict[str, Any] | None = None, prompt_build_context: dict[str, Any] | None = None,
turn_uuid: str | None = None,
) -> TurnRunOutcome: ) -> TurnRunOutcome:
"""A minimal oclaw-style loop: model -> tool_uses -> execute -> tool_results -> continue.""" """A minimal oclaw-style loop: model -> tool_uses -> execute -> tool_results -> continue."""
_check_stop(should_stop) _check_stop(should_stop)
turn_uuid = str(uuid.uuid4()) turn_uuid = str(turn_uuid or "").strip() or str(uuid.uuid4())
if persist_user_message: if persist_user_message:
store.add_message( store.add_message(
session_id=session_id, session_id=session_id,
@ -689,10 +948,12 @@ def run_oclaw_direct_loop(
skill_exec = SkillExecutor(config=ToolExecutionConfig(max_workers=max(1, min(int(max_tool_workers or 8), 32)))) skill_exec = SkillExecutor(config=ToolExecutionConfig(max_workers=max(1, min(int(max_tool_workers or 8), 32))))
tool_traces: list[dict[str, Any]] = [] tool_traces: list[dict[str, Any]] = []
final_text = "" final_text = ""
hit_tool_round_limit = False
base_url = str(getattr(model, "base_url", "") or "") base_url = str(getattr(model, "base_url", "") or "")
for round_idx in range(max(1, int(max_tool_rounds or 1))): max_rounds = max(1, int(max_tool_rounds or 1))
for round_idx in range(max_rounds):
_check_stop(should_stop) _check_stop(should_stop)
if on_progress: if on_progress:
on_progress(f"oclaw: think ({round_idx + 1})…") on_progress(f"oclaw: think ({round_idx + 1})…")
@ -715,6 +976,7 @@ def run_oclaw_direct_loop(
skill_binding_role=skill_binding_role, skill_binding_role=skill_binding_role,
user_text=str(user_text or ""), user_text=str(user_text or ""),
prompt_build_context=prompt_build_context, prompt_build_context=prompt_build_context,
active_turn_uuid=turn_uuid,
) )
llm_tools = _prepare_llm_tools( llm_tools = _prepare_llm_tools(
store=store, store=store,
@ -744,6 +1006,10 @@ def run_oclaw_direct_loop(
final_text = step.assistant_text final_text = step.assistant_text
if not step.llm_tool_calls: if not step.llm_tool_calls:
break break
if round_idx == (max_rounds - 1):
# Reached tool-round cap with pending tool calls. Execute this batch, then
# force one no-tool synthesis pass to guarantee a visible assistant body.
hit_tool_round_limit = True
elapsed_ms, results_by_id = _execute_tool_step( elapsed_ms, results_by_id = _execute_tool_step(
skill_exec=skill_exec, skill_exec=skill_exec,
@ -783,6 +1049,42 @@ def run_oclaw_direct_loop(
if on_progress: if on_progress:
on_progress(f"oclaw: tools done ({elapsed_ms}ms)") on_progress(f"oclaw: tools done ({elapsed_ms}ms)")
if hit_tool_round_limit:
_check_stop(should_stop)
if on_progress:
on_progress("oclaw: finalize…")
msgs = _build_model_context(
store=store,
session_id=session_id,
max_messages=max_messages,
system_prompt=system_prompt,
model=model,
lang=lang,
memory_context=memory_context,
trace_id=trace_id,
parent_span_id=parent_span_id,
tools=tools,
base_url=base_url,
run_id=run_id,
attempt_no=attempt_no,
workspace_dir=workspace_dir,
skill_binding_role=skill_binding_role,
user_text=str(user_text or ""),
prompt_build_context=prompt_build_context,
active_turn_uuid=turn_uuid,
)
# Final pass forbids extra tool calls; model must synthesize answer.
resp = model.chat(msgs, [], on_token=on_token)
step = _persist_assistant_step(
store=store,
session_id=session_id,
turn_uuid=turn_uuid,
assistant_text=str(getattr(resp, "content", "") or ""),
reasoning_text=str(getattr(resp, "reasoning_content", "") or ""),
llm_tool_calls=[],
)
final_text = step.assistant_text
return TurnRunOutcome( return TurnRunOutcome(
final_text=str(final_text or ""), final_text=str(final_text or ""),
tool_traces=tuple(tool_traces), tool_traces=tuple(tool_traces),

View file

@ -34,6 +34,7 @@ from oclaw.runtime.memory_stage import after_turn_memory
from oclaw.runtime.router import decide_route from oclaw.runtime.router import decide_route
from oclaw.runtime.worker import ensure_worker_started from oclaw.runtime.worker import ensure_worker_started
from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id
from oclaw.runtime.chat.tool_runtime import compact_turn_tool_messages_for_storage
_OC_STAGE_BY_EVENT: dict[str, str] = { _OC_STAGE_BY_EVENT: dict[str, str] = {
"gateway_received": "ingress", "gateway_received": "ingress",
@ -91,6 +92,7 @@ class OclawGateway:
start = t.find("{") start = t.find("{")
if start < 0: if start < 0:
return None return None
executed_turn_uuid = ""
try: try:
obj, _end = json.JSONDecoder().raw_decode(t[start:]) obj, _end = json.JSONDecoder().raw_decode(t[start:])
except Exception: except Exception:
@ -414,6 +416,36 @@ class OclawGateway:
return True return True
return False return False
@staticmethod
def _has_text_ref_attachments(msg: StandardMessage) -> bool:
atts = msg.attachments if isinstance(msg.attachments, list) else []
for a in atts:
if isinstance(a, dict) and str(a.get("type") or "").strip().lower() == "text_ref":
return True
return False
@staticmethod
def _has_image_ref_attachments(msg: StandardMessage) -> bool:
atts = msg.attachments if isinstance(msg.attachments, list) else []
for a in atts:
if not isinstance(a, dict):
continue
t = str(a.get("type") or "").strip().lower()
if t in {"image_ref", "image", "input_image"}:
return True
return False
@staticmethod
def _has_video_ref_attachments(msg: StandardMessage) -> bool:
atts = msg.attachments if isinstance(msg.attachments, list) else []
for a in atts:
if not isinstance(a, dict):
continue
t = str(a.get("type") or "").strip().lower()
if t == "video_ref":
return True
return False
@staticmethod @staticmethod
def _tabular_query_system_hint(lang: str) -> str: def _tabular_query_system_hint(lang: str) -> str:
limits = OclawGateway._tabular_limits_from_config() limits = OclawGateway._tabular_limits_from_config()
@ -431,6 +463,38 @@ class OclawGateway:
"如果需要更多行或更细节,请通过数据库工具(`query_tabular_attachment` / `run_tabular_sql`)结合 table_id 查询。" "如果需要更多行或更细节,请通过数据库工具(`query_tabular_attachment` / `run_tabular_sql`)结合 table_id 查询。"
) )
@staticmethod
def _text_query_system_hint(lang: str) -> str:
if str(lang or "").startswith("en"):
return (
"For long text attachments: context may contain only summary/preview. "
"For detailed evidence, use `query_text_attachment` with `text_id` from `text_ref` attachment."
)
return (
"对于长文本附件:上下文可能只包含摘要/预览。"
"如需细节证据,请使用 `text_ref` 提供的 text_id 调用 `query_text_attachment`。"
)
@staticmethod
def _image_query_system_hint(lang: str) -> str:
if str(lang or "").startswith("en"):
return (
"For image attachments: use `query_image_attachment` with attachment_id "
"for OCR/description when visual evidence is required."
)
return (
"对于图片附件:如需 OCR 或图像细节,请使用 attachment_id 调用 `query_image_attachment`。"
)
@staticmethod
def _video_query_system_hint(lang: str) -> str:
if str(lang or "").startswith("en"):
return (
"For video attachments: use `query_video_attachment` with attachment_id from `video_ref` "
"to get metadata or transcript (if enabled)."
)
return "对于视频附件:请使用 `video_ref` 提供的 attachment_id 调用 `query_video_attachment` 获取元信息/转写。"
@staticmethod @staticmethod
def _tabular_limits_from_config() -> dict[str, int]: def _tabular_limits_from_config() -> dict[str, int]:
cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip() cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
@ -767,6 +831,25 @@ class OclawGateway:
}, },
started_at=t0, started_at=t0,
) )
if str(manager_instruction_text or "").strip() and not bool(manager_memory_mode):
try:
assignment_title = "Task assignment" if str(lang or "").startswith("en") else "任务分配"
assignment_text = (
f"{assignment_title}\n"
f"specialist={str(manager_specialist or '')}\n"
f"instruction:\n{str(manager_instruction_text or '').strip()}"
)
self.store.add_message(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role="assistant",
content=assignment_text,
tool_calls=None,
event_type="reasoning",
)
except Exception:
pass
# Build specialist/dynamic executor and dispatch only manager instruction to it. # Build specialist/dynamic executor and dispatch only manager instruction to it.
specialist_input_msg = StandardMessage( specialist_input_msg = StandardMessage(
session_id=msg.session_id, session_id=msg.session_id,
@ -984,6 +1067,12 @@ class OclawGateway:
sys_prompt = str(getattr(selected_executor, "system_prompt", "") or "") sys_prompt = str(getattr(selected_executor, "system_prompt", "") or "")
if self._has_tabular_ref_attachments(msg): if self._has_tabular_ref_attachments(msg):
sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip() sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip()
if self._has_text_ref_attachments(msg):
sys_prompt = f"{sys_prompt}\n\n{self._text_query_system_hint(lang)}".strip()
if self._has_image_ref_attachments(msg):
sys_prompt = f"{sys_prompt}\n\n{self._image_query_system_hint(lang)}".strip()
if self._has_video_ref_attachments(msg):
sys_prompt = f"{sys_prompt}\n\n{self._video_query_system_hint(lang)}".strip()
def _get_int_setting(key: str, default: int, lo: int, hi: int) -> int: def _get_int_setting(key: str, default: int, lo: int, hi: int) -> int:
try: try:
@ -1042,6 +1131,7 @@ class OclawGateway:
wire_policy_role="manager" if interaction_mode == "comprehensive" else str(requested_specialist), wire_policy_role="manager" if interaction_mode == "comprehensive" else str(requested_specialist),
), ),
) )
executed_turn_uuid = str(getattr(core_out.outcome, "turn_uuid", "") or "")
specialist_reply = str(core_out.outcome.final_text or "") specialist_reply = str(core_out.outcome.final_text or "")
if manager_memory_mode: if manager_memory_mode:
# manager_memory: write memory silently, but keep dialog output independent. # manager_memory: write memory silently, but keep dialog output independent.
@ -1095,6 +1185,15 @@ class OclawGateway:
) )
except Exception: except Exception:
pass pass
if str(executed_turn_uuid or "").strip():
try:
compact_turn_tool_messages_for_storage(
store=self.store,
session_id=msg.session_id,
turn_uuid=executed_turn_uuid,
)
except Exception:
pass
_trace_local( _trace_local(
event_type="response_sent", event_type="response_sent",
payload={"ok": bool(str(reply or "").strip()), "elapsed_ms": elapsed_ms, "mode": "sync_direct"}, payload={"ok": bool(str(reply or "").strip()), "elapsed_ms": elapsed_ms, "mode": "sync_direct"},

View file

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Any
from oclaw.platform.files.attachment_assets import attachment_id_to_data_url
from oclaw.platform.llm.image_message_client import send_image_messages
from oclaw.runtime.tools.base import ToolSpec
def query_image_attachment_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
attachment_id = str(args.get("attachment_id") or "").strip()
if not attachment_id:
return {"ok": False, "error": "attachment_id_required"}
task = str(args.get("task") or "describe").strip().lower()
question = str(args.get("question") or "").strip()
if task not in {"describe", "ocr"}:
return {"ok": False, "error": "invalid_task"}
data_url = attachment_id_to_data_url(attachment_id=attachment_id)
if not data_url:
return {"ok": False, "error": "attachment_not_found"}
prompt = (
(
"请详细描述这张图片的主要内容、对象、场景和可见文字。"
"回答请使用要点列表,避免臆测。"
)
if task == "describe"
else (
"请只提取图片中可见文字并按阅读顺序输出。"
"如果有表格,保持行列结构;不确定的内容标注为[unclear]。"
)
)
if question:
prompt = f"{prompt}\n\n用户问题:{question}"
out = send_image_messages(images=[data_url], prompt=prompt)
if not bool(out.get("ok")):
return {
"ok": False,
"error": str(out.get("error") or "image_query_failed"),
"task": task,
"attachment_id": attachment_id,
}
text = str(out.get("text") or "")
if len(text) > 12_000:
text = text[:12_000] + "\n\n...[truncated image analysis output]"
return {
"ok": True,
"task": task,
"attachment_id": attachment_id,
"text": text,
"input_kind": list(out.get("input_kind") or []),
"backend_shape": str(out.get("backend_shape") or ""),
}
return ToolSpec(
name="query_image_attachment",
description="Analyze an uploaded image by attachment_id (describe or OCR).",
parameters={
"type": "object",
"properties": {
"attachment_id": {"type": "string"},
"task": {"type": "string", "enum": ["describe", "ocr"]},
"question": {"type": "string"},
},
"required": ["attachment_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
tags=frozenset({"image", "read"}),
)
__all__ = ["query_image_attachment_tool"]

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from oclaw.platform.files.text_attachment_store import query_text_document
from oclaw.runtime.tools.base import ToolSpec
def query_text_attachment_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
text_id = str(args.get("text_id") or "").strip()
if not text_id:
return {"ok": False, "error": "text_id_required"}
return query_text_document(
text_id=text_id,
query=str(args.get("query") or "").strip() or None,
top_k=int(args.get("top_k") or 5),
offset=int(args.get("offset") or 0),
)
return ToolSpec(
name="query_text_attachment",
description="Query long text attachment chunks by text_id with optional keyword search.",
parameters={
"type": "object",
"properties": {
"text_id": {"type": "string"},
"query": {"type": "string"},
"top_k": {"type": "integer", "minimum": 1, "maximum": 50},
"offset": {"type": "integer", "minimum": 0},
},
"required": ["text_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
__all__ = ["query_text_attachment_tool"]

View file

@ -0,0 +1,278 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import io
import json
from pathlib import Path
from typing import Any
from oclaw.platform.files.attachment_assets import AttachmentAssetStore
from oclaw.platform.files.text_attachment_store import (
DEFAULT_TEXT_CHUNK_OVERLAP,
DEFAULT_TEXT_CHUNK_SIZE,
save_text_document,
)
from oclaw.runtime.extensions.openai.api import OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL
from oclaw.runtime.tools.base import ToolSpec
def _ffmpeg_exists() -> bool:
try:
p = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=3)
return p.returncode == 0
except Exception:
return False
def _ffprobe_json(path: Path) -> dict[str, Any] | None:
try:
p = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-print_format",
"json",
"-show_format",
"-show_streams",
str(path),
],
capture_output=True,
text=True,
timeout=8,
)
if p.returncode != 0:
return None
obj = json.loads(p.stdout or "{}")
return obj if isinstance(obj, dict) else None
except Exception:
return None
def _safe_int(raw: Any, default: int, *, min_value: int = 1, max_value: int = 2_000_000) -> int:
try:
value = int(raw)
except Exception:
return default
if value < min_value:
return default
return min(value, max_value)
def _oclaw_config_path() -> Path:
raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
if raw:
p = Path(raw)
return p if p.is_absolute() else p.resolve()
return Path(__file__).resolve().parents[4] / "oclaw.json"
def _video_transcript_chunk_defaults() -> tuple[int, int]:
size = DEFAULT_TEXT_CHUNK_SIZE
overlap = DEFAULT_TEXT_CHUNK_OVERLAP
try:
cfg_path = _oclaw_config_path()
if cfg_path.exists() and cfg_path.is_file():
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
tab = (
(((obj.get("plugins") or {}).get("entries") or {}).get("memory-wiki") or {})
.get("auto", {})
.get("attachments", {})
.get("tabular", {})
)
if isinstance(tab, dict):
size = _safe_int(tab.get("video_transcript_chunk_size"), size, min_value=200, max_value=8_000)
overlap = _safe_int(tab.get("video_transcript_chunk_overlap"), overlap, min_value=0, max_value=4_000)
except Exception:
pass
overlap = max(0, min(overlap, max(0, size - 1)))
return size, overlap
def _normalized_video_meta(ffprobe_obj: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not isinstance(ffprobe_obj, dict):
return out
fmt = ffprobe_obj.get("format") if isinstance(ffprobe_obj.get("format"), dict) else {}
streams = ffprobe_obj.get("streams") if isinstance(ffprobe_obj.get("streams"), list) else []
if isinstance(fmt, dict) and fmt.get("duration") is not None:
try:
out["duration_sec"] = float(fmt.get("duration"))
except Exception:
pass
for s in streams:
if not isinstance(s, dict):
continue
if str(s.get("codec_type") or "") != "video":
continue
try:
if s.get("width") is not None:
out["width"] = int(s.get("width"))
if s.get("height") is not None:
out["height"] = int(s.get("height"))
except Exception:
pass
fr = str(s.get("avg_frame_rate") or s.get("r_frame_rate") or "").strip()
if fr and fr != "0/0" and "/" in fr:
try:
a, b = fr.split("/", 1)
fa = float(a)
fb = float(b)
if fb:
out["fps"] = fa / fb
except Exception:
pass
break
return out
def query_video_attachment_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
attachment_id = str(args.get("attachment_id") or "").strip()
task = str(args.get("task") or "meta").strip().lower()
lang = str(args.get("lang") or "").strip().lower()
if not attachment_id:
return {"ok": False, "error": "attachment_id_required"}
if task not in {"meta", "transcript"}:
return {"ok": False, "error": "invalid_task"}
store = AttachmentAssetStore()
p = store.get_local_path(attachment_id)
meta = store.get_meta(attachment_id)
if p is None:
return {"ok": False, "error": "attachment_not_found"}
# Basic metadata (no heavy deps). Prefer ffprobe if present.
if task == "meta":
fp = _ffprobe_json(p)
norm = _normalized_video_meta(fp)
return {
"ok": True,
"task": "meta",
"attachment_id": attachment_id,
"name": (meta.name if meta else p.name),
"mime": (meta.mime if meta else "video/*"),
"bytes": int(meta.bytes if meta else (p.stat().st_size if p.exists() else 0)),
"duration_sec": norm.get("duration_sec"),
"width": norm.get("width"),
"height": norm.get("height"),
"fps": norm.get("fps"),
"ffprobe": fp if fp else None,
"note": "Use task=transcript to extract audio transcript (requires ffmpeg + OpenAI key).",
}
# transcript: extract audio then transcribe via OpenAI, store as text chunks.
if task == "transcript":
if not _ffmpeg_exists():
return {
"ok": False,
"error": "ffmpeg_missing",
"hint": "Install ffmpeg (ffmpeg/ffprobe on PATH) to enable transcript extraction.",
}
api_key = str(os.getenv("OPENAI_API_KEY") or "").strip()
if not api_key:
return {"ok": False, "error": "OPENAI_API_KEY_missing"}
try:
with tempfile.TemporaryDirectory() as td:
wav = Path(td) / "audio.wav"
subprocess.run(
["ffmpeg", "-y", "-i", str(p), "-vn", "-ac", "1", "-ar", "16000", str(wav)],
capture_output=True,
text=True,
timeout=60,
)
if not wav.exists() or wav.stat().st_size <= 0:
return {"ok": False, "error": "audio_extract_failed"}
try:
from openai import OpenAI
except Exception as e:
return {"ok": False, "error": f"openai_package_missing: {type(e).__name__}: {e}"}
base_url = str(os.getenv("OPENAI_BASE_URL") or "").strip()
client_kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
client = OpenAI(**client_kwargs)
model = str(args.get("model") or os.getenv("OPENAI_AUDIO_TRANSCRIPTION_MODEL") or OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL).strip()
prompt = str(args.get("prompt") or "").strip()
# OpenAI SDK expects a file-like object with a name.
wav_bytes = wav.read_bytes()
f = io.BytesIO(wav_bytes)
f.name = "audio.wav" # type: ignore[attr-defined]
# Best-effort: different gateways may accept different param names; keep it minimal.
try:
resp = client.audio.transcriptions.create( # type: ignore[attr-defined]
model=model,
file=f,
**({"prompt": prompt} if prompt else {}),
)
text = str(getattr(resp, "text", "") or "")
except Exception as e:
return {"ok": False, "error": f"transcription_failed: {type(e).__name__}: {e}"}
if not text.strip():
return {"ok": False, "error": "empty_transcript"}
# Persist transcript as a long text document so the model can query evidence by text_id.
name = str(meta.name if meta else p.name)
text_name = f"{name}.transcript.txt"
cfg_chunk_size, cfg_chunk_overlap = _video_transcript_chunk_defaults()
chunk_size = int(args.get("chunk_size") or cfg_chunk_size)
chunk_overlap = int(args.get("chunk_overlap") or cfg_chunk_overlap)
text_meta = save_text_document(
attachment_id=str(attachment_id),
name=text_name,
text=text,
source_kind="video_transcript",
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
preview = text[:1200]
note = (
"Use query_text_attachment(text_id=...) to retrieve exact evidence with offsets."
if lang.startswith("en")
else "后续请用 query_text_attachment(text_id=...) 按需检索证据(支持 offset/top_k/关键词)。"
)
return {
"ok": True,
"task": "transcript",
"attachment_id": attachment_id,
"name": text_name,
"text_id": str(text_meta.get("text_id") or ""),
"chars": int(text_meta.get("chars") or 0),
"chunks": int(text_meta.get("chunks") or 0),
"preview": preview,
"note": note,
}
except Exception as e:
return {"ok": False, "error": f"transcript_failed: {type(e).__name__}: {e}"}
return ToolSpec(
name="query_video_attachment",
description="Query a video attachment by attachment_id (meta or transcript).",
parameters={
"type": "object",
"properties": {
"attachment_id": {"type": "string"},
"task": {"type": "string", "enum": ["meta", "transcript"]},
"lang": {"type": "string", "description": "Optional hint: zh/en."},
"model": {"type": "string", "description": "Optional transcription model override."},
"prompt": {"type": "string", "description": "Optional transcription prompt/context."},
"chunk_size": {"type": "integer", "description": "Transcript chunk size (chars)."},
"chunk_overlap": {"type": "integer", "description": "Transcript chunk overlap (chars)."},
},
"required": ["attachment_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
tags=frozenset({"video", "read"}),
)
__all__ = ["query_video_attachment_tool"]

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from oclaw.runtime.tools.experts.generalist.image_query import query_image_attachment_tool
__all__ = ["query_image_attachment_tool"]

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from oclaw.runtime.tools.experts.generalist.text_query import query_text_attachment_tool
__all__ = ["query_text_attachment_tool"]

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from oclaw.runtime.tools.experts.generalist.video_query import query_video_attachment_tool
__all__ = ["query_video_attachment_tool"]

View file

@ -0,0 +1,166 @@
from __future__ import annotations
import json
from pathlib import Path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.direct_loop import _build_model_context
from oclaw.platform.llm.chat_models import RuleBasedChatModel
def test_large_text_attachment_is_guarded_in_history_context(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "t.sqlite"))
sess = store.create_session("t")
big = "X" * 10000
attachments = [{"type": "text", "name": "big.txt", "content": big}]
store.add_message(session_id=sess.id, role="user", content="hi", attachments=attachments)
msgs = _build_model_context(
store=store,
session_id=sess.id,
max_messages=50,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="en",
memory_context=None,
trace_id=None,
parent_span_id=None,
tools=None,
base_url="",
user_text="",
prompt_build_context=None,
active_turn_uuid="different-turn",
)
# Ensure guard marker appears in injected context.
joined = json.dumps(msgs, ensure_ascii=False)
assert "Attachment (summarized for context replay)" in joined
assert "attachment_truncated_for_context_replay" in joined
def test_text_attachment_is_collapsed_when_text_ref_present(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "t2.sqlite"))
sess = store.create_session("t")
big = "Y" * 6000
attachments = [
{"type": "text", "name": "doc.txt", "content": big},
{"type": "text_ref", "name": "doc.txt", "text_id": "a" * 64, "chars": 6000, "chunks": 4, "source_kind": "txt"},
]
store.add_message(session_id=sess.id, role="user", content="hi", attachments=attachments)
msgs = _build_model_context(
store=store,
session_id=sess.id,
max_messages=50,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="en",
memory_context=None,
trace_id=None,
parent_span_id=None,
tools=None,
base_url="",
user_text="",
prompt_build_context=None,
active_turn_uuid="different-turn",
)
joined = json.dumps(msgs, ensure_ascii=False)
assert "Attachment (collapsed; text_ref available)" in joined
assert "attachment_collapsed_for_context_replay" in joined
def test_large_image_tool_result_is_guarded_in_history_context(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "t3.sqlite"))
sess = store.create_session("t")
long_text = "OCR-LINE\n" * 1200
payload = {
"ok": True,
"task": "ocr",
"attachment_id": "b" * 64,
"text": long_text,
"backend_shape": "multi",
}
store.add_message(session_id=sess.id, role="tool", content=json.dumps(payload, ensure_ascii=False))
msgs = _build_model_context(
store=store,
session_id=sess.id,
max_messages=50,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="en",
memory_context=None,
trace_id=None,
parent_span_id=None,
tools=None,
base_url="",
user_text="",
prompt_build_context=None,
active_turn_uuid="different-turn",
)
joined = json.dumps(msgs, ensure_ascii=False)
assert "image_tool_result_truncated_for_context_replay" in joined
assert "_image_tool_result_guarded" in joined
def test_small_image_tool_result_not_guarded(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "t4.sqlite"))
sess = store.create_session("t")
payload = {
"ok": True,
"task": "describe",
"attachment_id": "c" * 64,
"text": "A concise description of an icon.",
}
store.add_message(session_id=sess.id, role="tool", content=json.dumps(payload, ensure_ascii=False))
msgs = _build_model_context(
store=store,
session_id=sess.id,
max_messages=50,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="en",
memory_context=None,
trace_id=None,
parent_span_id=None,
tools=None,
base_url="",
user_text="",
prompt_build_context=None,
active_turn_uuid="different-turn",
)
joined = json.dumps(msgs, ensure_ascii=False)
assert "image_tool_result_truncated_for_context_replay" not in joined
def test_large_video_transcript_tool_result_is_guarded_in_history_context(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "t5.sqlite"))
sess = store.create_session("t")
long_text = "LINE\n" * 2000
payload = {
"ok": True,
"task": "transcript",
"attachment_id": "d" * 64,
"text": long_text,
}
store.add_message(session_id=sess.id, role="tool", content=json.dumps(payload, ensure_ascii=False))
msgs = _build_model_context(
store=store,
session_id=sess.id,
max_messages=50,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="en",
memory_context=None,
trace_id=None,
parent_span_id=None,
tools=None,
base_url="",
user_text="",
prompt_build_context=None,
active_turn_uuid="different-turn",
)
joined = json.dumps(msgs, ensure_ascii=False)
assert "video_tool_result_truncated_for_context_replay" in joined

View file

@ -2,6 +2,8 @@ from __future__ import annotations
import io import io
import json import json
import gzip
import tarfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@ -15,6 +17,7 @@ from oclaw.platform.files.tabular_attachment_store import (
run_table_sql, run_table_sql,
save_workbook, save_workbook,
) )
from oclaw.platform.files.text_attachment_store import query_text_document
def _zip_bytes(files: dict[str, bytes]) -> bytes: def _zip_bytes(files: dict[str, bytes]) -> bytes:
@ -25,6 +28,17 @@ def _zip_bytes(files: dict[str, bytes]) -> bytes:
return buf.getvalue() return buf.getvalue()
def _tar_bytes(files: dict[str, bytes], *, gz: bool = False) -> bytes:
buf = io.BytesIO()
mode = "w:gz" if gz else "w"
with tarfile.open(fileobj=buf, mode=mode) as tf:
for name, data in files.items():
ti = tarfile.TarInfo(name=name)
ti.size = len(data)
tf.addfile(ti, io.BytesIO(data))
return buf.getvalue()
def test_csv_is_summarized_not_full_dump() -> None: def test_csv_is_summarized_not_full_dump() -> None:
payload = ( payload = (
"c1,c2,c3\n" "c1,c2,c3\n"
@ -37,6 +51,32 @@ def test_csv_is_summarized_not_full_dump() -> None:
assert "# Table Summary" in content assert "# Table Summary" in content
assert "rows: 2" in content assert "rows: 2" in content
assert "cols: 3" in content assert "cols: 3" in content
assert "## Full table included (2 rows)" in content
def test_long_txt_emits_text_ref_and_can_query() -> None:
payload = ("A" * 15000 + "\nneedle\n" + "B" * 2000).encode("utf-8")
out = process_file_data("notes.txt", payload)
text_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "text_ref"]
assert text_refs
text_id = str(text_refs[0].get("text_id") or "")
assert text_id
got = query_text_document(text_id=text_id, query="needle", top_k=3, offset=0)
assert bool(got.get("ok"))
rows = got.get("rows") or []
assert rows
assert any("needle" in str(x.get("content") or "") for x in rows)
def test_video_upload_emits_video_ref() -> None:
# Minimal MP4 header-like bytes; parser should not crash and should still store as video_ref.
payload = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
out = process_file_data("clip.mp4", payload)
vrefs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "video_ref"]
assert vrefs
vr = vrefs[0]
assert str(vr.get("attachment_id") or "")
assert str(vr.get("mime") or "").startswith("video/")
def test_zip_file_count_limit_returns_error_attachment() -> None: def test_zip_file_count_limit_returns_error_attachment() -> None:
@ -48,6 +88,104 @@ def test_zip_file_count_limit_returns_error_attachment() -> None:
assert "too many files" in str(first.get("content") or "").lower() assert "too many files" in str(first.get("content") or "").lower()
def test_tar_path_traversal_is_blocked() -> None:
payload = _tar_bytes({"../evil.txt": b"x"})
out = process_file_data("unsafe.tar", payload)
assert out
first = out[0]
assert str(first.get("name") or "") == "archive-error"
assert "unsafe path" in str(first.get("content") or "").lower()
def test_tgz_is_parsed_and_members_processed() -> None:
payload = _tar_bytes({"ok.txt": b"hello"}, gz=True)
out = process_file_data("bundle.tgz", payload)
assert out
assert any(str(x.get("type") or "") == "text" and "hello" in str(x.get("content") or "") for x in out)
def test_tar_link_entry_is_explicitly_rejected() -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tf:
ti = tarfile.TarInfo(name="target.txt")
data = b"ok"
ti.size = len(data)
tf.addfile(ti, io.BytesIO(data))
lnk = tarfile.TarInfo(name="sym")
lnk.type = tarfile.SYMTYPE
lnk.linkname = "target.txt"
tf.addfile(lnk)
out = process_file_data("has-link.tar", buf.getvalue())
assert out
first = out[0]
assert str(first.get("name") or "") == "archive-error"
assert str(first.get("error_code") or "") == "archive_link_entry_forbidden"
def test_gz_single_file_is_decompressed_and_processed() -> None:
payload = gzip.compress(b"hello-gz")
out = process_file_data("note.txt.gz", payload)
assert out
assert any(str(x.get("type") or "") == "text" and "hello-gz" in str(x.get("content") or "") for x in out)
def test_rar_returns_explicit_unsupported_error() -> None:
out = process_file_data("bundle.rar", b"not-a-real-rar")
assert out
first = out[0]
assert str(first.get("name") or "") == "archive-error"
assert "unsupported archive format" in str(first.get("content") or "").lower()
def test_7z_returns_explicit_unsupported_error() -> None:
out = process_file_data("bundle.7z", b"not-a-real-7z")
assert out
first = out[0]
assert str(first.get("name") or "") == "archive-error"
assert "unsupported archive format" in str(first.get("content") or "").lower()
assert str(first.get("error_code") or "") == "archive_unsupported_format"
def test_archive_signature_detection_overrides_misleading_extension() -> None:
payload = _zip_bytes({"ok.txt": b"sig-ok"})
out = process_file_data("misleading.tar", payload)
assert out
assert any(str(x.get("type") or "") == "text" and "sig-ok" in str(x.get("content") or "") for x in out)
def test_archive_limits_can_be_overridden_by_config(tmp_path: Path, monkeypatch) -> None:
cfg = {
"plugins": {
"entries": {
"memory-wiki": {
"auto": {
"attachments": {
"tabular": {
"archive_max_depth": 1,
"archive_max_file_count": 2,
"archive_max_entry_bytes": 20,
"archive_max_total_uncompressed_bytes": 30,
}
}
}
}
}
}
}
cfg_path = tmp_path / "oclaw.json"
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
monkeypatch.setenv("AIA_OCLAW_CONFIG_PATH", str(cfg_path))
fa._attachments_limits.cache_clear()
try:
payload = _zip_bytes({"a.txt": b"x" * 50})
out = process_file_data("limited.zip", payload)
finally:
fa._attachments_limits.cache_clear()
assert out
first = out[0]
assert "entry too large" in str(first.get("content") or "").lower()
def test_nested_zip_depth_limit_is_enforced() -> None: def test_nested_zip_depth_limit_is_enforced() -> None:
z4 = _zip_bytes({"deep.txt": b"hello"}) z4 = _zip_bytes({"deep.txt": b"hello"})
z3 = _zip_bytes({"z4.zip": z4}) z3 = _zip_bytes({"z4.zip": z4})
@ -197,6 +335,14 @@ def test_large_csv_emits_tabular_ref_and_can_query() -> None:
assert str(got.get("engine") or "") in {"builtin_sqlite", "mcp_sqlite"} assert str(got.get("engine") or "") in {"builtin_sqlite", "mcp_sqlite"}
def test_medium_csv_enters_tool_mode_with_default_threshold() -> None:
rows = ["c1,c2"] + [f"{i},row-{i}" for i in range(0, 6000)]
payload = ("\n".join(rows)).encode("utf-8")
out = process_file_data("medium-default.csv", payload)
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
assert tab_refs
def test_large_csv_can_aggregate_grouped_sum() -> None: def test_large_csv_can_aggregate_grouped_sum() -> None:
rows = ["dept,amount"] + [f"{'A' if i % 2 == 0 else 'B'},{i}" for i in range(0, 25010)] rows = ["dept,amount"] + [f"{'A' if i % 2 == 0 else 'B'},{i}" for i in range(0, 25010)]
payload = ("\n".join(rows)).encode("utf-8") payload = ("\n".join(rows)).encode("utf-8")
@ -324,6 +470,20 @@ def test_query_can_target_specific_excel_sheet() -> None:
assert rows and str(rows[0].get("k") or "") == "b" assert rows and str(rows[0].get("k") or "") == "b"
def test_multi_sheet_query_without_sheet_returns_sheet_hint() -> None:
s1 = pd.DataFrame([["a", "1"]], columns=["k", "v"])
s2 = pd.DataFrame([["b", "2"]], columns=["k", "v"])
meta = save_workbook(attachment_id="aid3", name="multi2.xlsx", sheets={"Main": s1, "Backup": s2})
tid = str(meta.get("table_id") or "")
got = query_table(table_id=tid, columns=["k", "v"], limit=5, offset=0)
assert bool(got.get("ok"))
hint = got.get("sheet_hint") or {}
assert isinstance(hint, dict)
names = hint.get("available_sheets") or []
assert "Main" in names and "Backup" in names
assert str(hint.get("default_sheet") or "") == "Main"
def test_full_scan_analyzes_all_rows_and_returns_audit() -> None: def test_full_scan_analyzes_all_rows_and_returns_audit() -> None:
rows = ["dept,score"] + [f"{'A' if i % 2 == 0 else 'B'},{i % 5}" for i in range(0, 25025)] rows = ["dept,score"] + [f"{'A' if i % 2 == 0 else 'B'},{i % 5}" for i in range(0, 25025)]
payload = ("\n".join(rows)).encode("utf-8") payload = ("\n".join(rows)).encode("utf-8")
@ -345,6 +505,25 @@ def test_full_scan_analyzes_all_rows_and_returns_audit() -> None:
assert isinstance(tops, list) and len(tops) <= 2 assert isinstance(tops, list) and len(tops) <= 2
def test_tabular_tools_reject_non_table_id_filename() -> None:
bad_id = "日志.xlsx"
q = query_table(table_id=bad_id, columns=["a"], limit=5, offset=0)
assert not bool(q.get("ok"))
assert str(q.get("error") or "") == "table_id_invalid_format"
ag = aggregate_table(table_id=bad_id, metric="count")
assert not bool(ag.get("ok"))
assert str(ag.get("error") or "") == "table_id_invalid_format"
sql = run_table_sql(table_id=bad_id, sql="SELECT 1", limit=5)
assert not bool(sql.get("ok"))
assert str(sql.get("error") or "") == "table_id_invalid_format"
fs = analyze_table_full_scan(table_id=bad_id, columns=["a"], top_values_limit=1)
assert not bool(fs.get("ok"))
assert str(fs.get("error") or "") == "table_id_invalid_format"
def test_xlsx_zip_safety_blocks_unsafe_paths() -> None: def test_xlsx_zip_safety_blocks_unsafe_paths() -> None:
payload = _zip_bytes({"../xl/workbook.xml": b"x"}) payload = _zip_bytes({"../xl/workbook.xml": b"x"})
out = process_file_data("bad.xlsx", payload) out = process_file_data("bad.xlsx", payload)

View file

@ -123,3 +123,272 @@ def test_repeated_non_sql_tools_are_not_compacted(tmp_path: Path) -> None:
assert payloads assert payloads
assert not any(bool(p.get("_history_compacted")) for p in payloads) assert not any(bool(p.get("_history_compacted")) for p in payloads)
def test_tabular_tools_blocked_without_tabular_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g4.sqlite"))
sess = store.create_session("t")
# A normal user turn without tabular_ref attachments.
store.add_message(session_id=sess.id, role="user", content="analyze this file", attachments=[{"type": "text", "name": "x"}])
def _handler(_args):
return {"ok": True, "rows": []}
reg = ToolRegistry(
[
ToolSpec(
name="query_tabular_attachment",
description="query table",
parameters={"type": "object", "properties": {"table_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_tabular_attachment", arguments={"table_id": "日志.xlsx"})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
blocked, _ = results["c1"]
assert not bool(blocked.get("ok"))
assert str(blocked.get("error_code") or "") == "tabular_ref_missing"
def test_tabular_tools_allowed_with_tabular_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g5.sqlite"))
sess = store.create_session("t")
store.add_message(
session_id=sess.id,
role="user",
content="uploaded table",
attachments=[{"type": "tabular_ref", "table_id": "a" * 64, "name": "ok.xlsx"}],
)
calls = {"n": 0}
def _handler(_args):
calls["n"] += 1
return {"ok": True, "rows": [{"x": 1}]}
reg = ToolRegistry(
[
ToolSpec(
name="query_tabular_attachment",
description="query table",
parameters={"type": "object", "properties": {"table_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_tabular_attachment", arguments={"table_id": "a" * 64})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
ok_res, _ = results["c1"]
assert bool(ok_res.get("ok"))
assert calls["n"] == 1
def test_text_tools_blocked_without_text_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g6.sqlite"))
sess = store.create_session("t")
store.add_message(session_id=sess.id, role="user", content="summarize", attachments=[{"type": "text", "name": "a.txt"}])
def _handler(_args):
return {"ok": True, "rows": []}
reg = ToolRegistry(
[
ToolSpec(
name="query_text_attachment",
description="query text",
parameters={"type": "object", "properties": {"text_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_text_attachment", arguments={"text_id": "a" * 64})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
blocked, _ = results["c1"]
assert not bool(blocked.get("ok"))
assert str(blocked.get("error_code") or "") == "text_ref_missing"
def test_text_tools_allowed_with_text_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g7.sqlite"))
sess = store.create_session("t")
store.add_message(
session_id=sess.id,
role="user",
content="uploaded long text",
attachments=[{"type": "text_ref", "text_id": "b" * 64, "name": "long.txt"}],
)
calls = {"n": 0}
def _handler(_args):
calls["n"] += 1
return {"ok": True, "rows": [{"x": 1}]}
reg = ToolRegistry(
[
ToolSpec(
name="query_text_attachment",
description="query text",
parameters={"type": "object", "properties": {"text_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_text_attachment", arguments={"text_id": "b" * 64})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
ok_res, _ = results["c1"]
assert bool(ok_res.get("ok"))
assert calls["n"] == 1
def test_image_tools_blocked_without_image_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g8.sqlite"))
sess = store.create_session("t")
store.add_message(session_id=sess.id, role="user", content="analyze image", attachments=[{"type": "text", "name": "x"}])
def _handler(_args):
return {"ok": True}
reg = ToolRegistry(
[
ToolSpec(
name="query_image_attachment",
description="query image",
parameters={"type": "object", "properties": {"attachment_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_image_attachment", arguments={"attachment_id": "abc"})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
blocked, _ = results["c1"]
assert not bool(blocked.get("ok"))
assert str(blocked.get("error_code") or "") == "image_ref_missing"
def test_image_tools_allowed_with_image_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g9.sqlite"))
sess = store.create_session("t")
store.add_message(
session_id=sess.id,
role="user",
content="uploaded image",
attachments=[{"type": "image_ref", "attachment_id": "abc", "mime": "image/png"}],
)
calls = {"n": 0}
def _handler(_args):
calls["n"] += 1
return {"ok": True}
reg = ToolRegistry(
[
ToolSpec(
name="query_image_attachment",
description="query image",
parameters={"type": "object", "properties": {"attachment_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_image_attachment", arguments={"attachment_id": "abc"})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
ok_res, _ = results["c1"]
assert bool(ok_res.get("ok"))
assert calls["n"] == 1
def test_video_tools_blocked_without_video_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g10.sqlite"))
sess = store.create_session("t")
store.add_message(session_id=sess.id, role="user", content="analyze video", attachments=[{"type": "text", "name": "x"}])
def _handler(_args):
return {"ok": True}
reg = ToolRegistry(
[
ToolSpec(
name="query_video_attachment",
description="query video",
parameters={"type": "object", "properties": {"attachment_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_video_attachment", arguments={"attachment_id": "abc"})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
blocked, _ = results["c1"]
assert not bool(blocked.get("ok"))
assert str(blocked.get("error_code") or "") == "video_ref_missing"
def test_video_tools_allowed_with_video_ref(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "g11.sqlite"))
sess = store.create_session("t")
store.add_message(
session_id=sess.id,
role="user",
content="uploaded video",
attachments=[{"type": "video_ref", "attachment_id": "abc", "mime": "video/mp4"}],
)
calls = {"n": 0}
def _handler(_args):
calls["n"] += 1
return {"ok": True}
reg = ToolRegistry(
[
ToolSpec(
name="query_video_attachment",
description="query video",
parameters={"type": "object", "properties": {"attachment_id": {"type": "string"}}},
handler=_handler,
read_only=True,
)
]
)
tool_uses = [LLMToolCall(id="c1", name="query_video_attachment", arguments={"attachment_id": "abc"})]
_, results = ToolExecutor().execute_tool_uses(
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
assistant_msg_id=1,
tool_uses=tool_uses,
)
ok_res, _ = results["c1"]
assert bool(ok_res.get("ok"))
assert calls["n"] == 1

View file

@ -0,0 +1,19 @@
from __future__ import annotations
import oclaw.runtime.tools.experts.generalist.video_query as vq
def test_normalized_video_meta_extracts_top_level_fields() -> None:
obj = {
"format": {"duration": "12.50"},
"streams": [
{"codec_type": "audio", "sample_rate": "48000"},
{"codec_type": "video", "width": 1920, "height": 1080, "avg_frame_rate": "30000/1001"},
],
}
out = vq._normalized_video_meta(obj)
assert float(out.get("duration_sec") or 0.0) > 12.0
assert int(out.get("width") or 0) == 1920
assert int(out.get("height") or 0) == 1080
assert float(out.get("fps") or 0.0) > 29.0