diff --git a/README.md b/README.md
index c015e336..7dcc7ad4 100644
--- a/README.md
+++ b/README.md
@@ -16,3 +16,29 @@ This repository is fully consolidated under `oclaw/`.
- Use `oclaw` consistently in paths, symbols, and docs.
- 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.
+
diff --git a/data/mcp_local.env.example b/data/mcp_local.env.example
index b3e3f6ef..7993fe7d 100644
--- a/data/mcp_local.env.example
+++ b/data/mcp_local.env.example
@@ -31,6 +31,15 @@
#
# Oclaw runtime 默认不回退 legacy manager/runner;若紧急排障需临时回退,可显式打开:
# 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 配置):
# AIA_TURN_MAX_TOOL_WORKERS=8
diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md
index 076b6e26..bbdd8a85 100644
--- a/docs/ENVIRONMENT_VARIABLES.md
+++ b/docs/ENVIRONMENT_VARIABLES.md
@@ -181,6 +181,34 @@
- 作用:`tool_log` 中 args/result 截断上限
- 生效:`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 与工具线侧
- `AIA_MCP_SPECIALISTS`
diff --git a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md
index bbef8b4b..4335fd64 100644
--- a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md
+++ b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md
@@ -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
### Added
diff --git a/interfaces/admin/chat_api.py b/interfaces/admin/chat_api.py
index e8e35a54..312c5948 100644
--- a/interfaces/admin/chat_api.py
+++ b/interfaces/admin/chat_api.py
@@ -40,7 +40,7 @@ def _oclaw_config_path() -> Path:
p = Path(raw)
return p if p.is_absolute() else p.resolve()
# 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:
@@ -103,6 +103,15 @@ _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_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())
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:
@@ -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),
"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_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),
"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),
"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_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)),
"sql_timeout_ms": int(
_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}"
+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:
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 "")
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)
+ 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 {
"ok": True,
"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
)
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}
@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)
if not sess:
raise HTTPException(status_code=404, detail="session_not_found")
- mode_key = _chat_session_mode_setting_key(
- tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
+ interaction_mode, specialist, memory_mode = _resolve_mode_settings(
+ 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}
@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"))
specialist = normalize_requested_specialist(payload.get("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)
- store.set_setting(mode_key, interaction_mode)
- store.set_setting(specialist_key, specialist)
- store.set_setting(memory_mode_key, memory_mode)
+ _persist_mode_settings(
+ store=store,
+ 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}
@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,
"large_table_preview_rows": 20,
"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,
"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)
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),
"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_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),
"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)
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)
memory_mode = _normalize_memory_mode(payload)
if "interaction_mode" not in payload and "chat_mode" not in payload:
- mode_key = _chat_session_mode_setting_key(
- tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
+ interaction_mode, _, _ = _resolve_mode_settings(
+ 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:
- specialist_key = _chat_session_mode_setting_key(
- tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
+ _, selected_specialist, _ = _resolve_mode_settings(
+ 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:
- 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 = _resolve_mode_settings(
+ 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)
if not text_raw and not attachments:
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)
memory_mode = _normalize_memory_mode(payload)
if "interaction_mode" not in payload and "chat_mode" not in payload:
- mode_key = _chat_session_mode_setting_key(
- tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
+ interaction_mode, _, _ = _resolve_mode_settings(
+ 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:
- specialist_key = _chat_session_mode_setting_key(
- tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
+ _, selected_specialist, _ = _resolve_mode_settings(
+ 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:
- 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 = _resolve_mode_settings(
+ 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)
if not text_raw and not attachments:
raise HTTPException(status_code=400, detail="text_or_attachments_required")
diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js
index 4954502e..7899f9fb 100644
--- a/interfaces/admin/static/app.js
+++ b/interfaces/admin/static/app.js
@@ -45,9 +45,21 @@ const I18N = {
"attachments.toolModeMinRows": "触发工具模式最小行数",
"attachments.toolModeMaxBytes": "工具模式最大文件字节数",
"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.invalidNumber": "请输入有效正整数",
"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.saved": "已保存",
"attachments.save": "保存",
@@ -452,9 +464,21 @@ const I18N = {
"attachments.toolModeMinRows": "Min rows to trigger tool mode",
"attachments.toolModeMaxBytes": "Max file bytes for tool mode",
"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.invalidNumber": "Please enter valid positive integers",
"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.saved": "Saved",
"attachments.save": "Save",
@@ -6399,6 +6423,14 @@ async function renderAttachments() {
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 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 n = parseInt(String(raw ?? "").trim(), 10);
@@ -6421,6 +6453,14 @@ async function renderAttachments() {
const minRows = parsePositiveInt(toolMinRowsInput.value);
const maxBytes = parsePositiveInt(toolMaxBytesInput.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 (
rows === null ||
cols === null ||
@@ -6429,7 +6469,15 @@ async function renderAttachments() {
previewRows === null ||
minRows === 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"));
}
@@ -6446,6 +6494,14 @@ async function renderAttachments() {
tool_mode_min_rows: minRows,
tool_mode_max_bytes: maxBytes,
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);
largePreviewRowsInput.value = String(l.large_table_preview_rows || 20);
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);
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 () => {
@@ -6538,6 +6602,18 @@ async function renderAttachments() {
inputRow(t("attachments.toolModeMaxBytes"), toolMaxBytesInput),
inputRow(t("attachments.sqlTimeoutMs"), sqlTimeoutInput),
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", { style: "height:8px" }),
status,
diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js
index 6d932cfe..ab3360bb 100644
--- a/interfaces/admin/static/chat.js
+++ b/interfaces/admin/static/chat.js
@@ -288,8 +288,14 @@ const CHAT_URL_SCOPE_KEY = "ops_chat_url_scope";
const CHAT_SPECIALIST_PREF_KEY = "ops_chat_specialist_pref";
const CHAT_INTERACTION_MODE_KEY = "ops_chat_interaction_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;
-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;
function _toolSummaryTitle(role) {
@@ -303,10 +309,33 @@ function _normalizeEventType(v) {
}
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");
box.className = "chat-msg__reasoning-block";
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;
}
@@ -1206,12 +1235,14 @@ function createStreamStitcher() {
};
}
-function formatToolPanelText(name, payload) {
+function formatToolPanelText(name, payload, options = {}) {
+ const streamMode = !!(options && options.streamMode);
const truncateToolPanel = (s) => {
const raw = String(s || "").trim();
- const maxChars = 50;
+ if (streamMode) return raw;
+ const maxChars = 4000;
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 p = payload && typeof payload === "object" ? payload : {};
@@ -1252,7 +1283,7 @@ function formatToolPanelText(name, payload) {
body = String(r || "");
}
}
- body = normalizeStreamText(body).replace(/\\n/g, "").replace(/\n/g, "");
+ body = normalizeStreamText(body);
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: "expert", text: t("chat.modeExpert") }));
- const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "comprehensive").toLowerCase();
- modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "comprehensive";
+ const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "expert").toLowerCase();
+ modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "expert";
const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default");
modeSelect.addEventListener("change", () => {
- localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive"));
+ localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert"));
saveSessionModePreference();
});
const applySpecialistOptions = () => {
@@ -1944,7 +1975,7 @@ async function renderChatUi() {
if (String(modeSelect.value || "").toLowerCase() !== "expert") {
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"));
const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default");
@@ -1955,9 +1986,9 @@ async function renderChatUi() {
if (!activeId) return;
try {
await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, {
- interaction_mode: String(modeSelect.value || "comprehensive"),
+ interaction_mode: String(modeSelect.value || "expert"),
specialist:
- String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
+ String(modeSelect.value || "expert").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist")
: "generalist",
memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
@@ -1975,7 +2006,7 @@ async function renderChatUi() {
]);
const syncModeSpecialistUi = () => {
- const m = String(modeSelect.value || "comprehensive").toLowerCase();
+ const m = String(modeSelect.value || "expert").toLowerCase();
const showSpecialist = m === "expert";
specialistLabelEl.style.display = showSpecialist ? "" : "none";
specialistSelect.style.display = showSpecialist ? "" : "none";
@@ -2069,22 +2100,12 @@ async function renderChatUi() {
topTopic = String(k || "");
}
}
- 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` }),
- });
- }
+ const actions = [];
return { kind: "info", text: t("chat.wikiToastMerged", { merged: String(merged), skipped: String(skipped) }), actions };
};
const pollWikiEvents = async () => {
+ if (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
const sid = String(activeId || "");
if (!sid) return;
const after = String((wikiAfterFinishedAt && wikiAfterFinishedAt[sid]) || "");
@@ -2129,6 +2150,7 @@ async function renderChatUi() {
};
const startWikiPoller = () => {
+ if (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
if (wikiPollTimerId != null) return;
wikiPollTimerId = setInterval(pollWikiEvents, 2200);
// kick once
@@ -2171,6 +2193,7 @@ async function renderChatUi() {
toolToggleBtn.addEventListener("click", () => {
showToolOutput = !showToolOutput;
adminChatShowToolOutput = showToolOutput;
+ localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0");
syncToolToggleBtn();
const isStreaming = composerShell.classList.contains("chat-composer-shell--busy");
if (isStreaming) {
@@ -2464,6 +2487,7 @@ async function renderChatUi() {
statusBar.textContent = t("chat.loading");
const resp = await apiPost("/admin/api/chat/sessions", {});
adoptCreatedSession(resp);
+ await loadSessionModePreference();
paintSessions();
messagesEl.innerHTML = "";
messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
@@ -2614,7 +2638,7 @@ async function renderChatUi() {
attachments: Array.isArray(attachments) ? attachments : [],
idempotencyKey: String(idempotencyKey || `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`),
thinking: "default",
- interaction_mode: String(interactionMode || "comprehensive"),
+ interaction_mode: String(interactionMode || "expert"),
specialist: String(specialist || "generalist"),
memory_mode: String(memoryMode || "default"),
},
@@ -2710,7 +2734,7 @@ async function renderChatUi() {
let currentStreamAbortController = null;
let currentWsTransport = null;
let currentAbortMeta = { sessionId: "", runId: "" };
- const WS_CHAT_SEND_TIMEOUT_MS = 60000;
+ const WS_CHAT_SEND_TIMEOUT_MS = 180000;
const isAbortError = (err) => {
const name = String(err && err.name ? err.name : "");
const msg = String(err && err.message ? err.message : err || "");
@@ -2856,10 +2880,12 @@ async function renderChatUi() {
const chunks = [];
for (const seg of chatStreamSegments) {
if (!seg || seg.type !== "text") continue;
- const txt = String(seg.text || "").trim();
+ const txt = String(seg.text || "");
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 bubble = ensureStreamBubble();
@@ -2902,7 +2928,7 @@ async function renderChatUi() {
const showLen = Math.max(0, Math.min(full.length, streamDisplayShown.length - textIdx));
const shown = full.slice(0, showLen);
textIdx += full.length;
- if (!shown.trim()) continue;
+ if (!shown) continue;
blocks.push(`
${escapeHtml(decodeEscapedNewlines(shown))}
`);
} else if (seg.type === "tool") {
if (!showToolOutput) continue;
@@ -2968,8 +2994,9 @@ ${autoLimit ? `auto-added claus
renderStreamComposite();
return;
}
- // Slightly faster typing while keeping typewriter feel for正文.
- const step = 2;
+ // Adaptive typing speed: when backlog grows, catch up quickly
+ // 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);
renderStreamComposite();
if (typingTimerId != null) {
@@ -2979,7 +3006,7 @@ ${autoLimit ? `auto-added claus
typingTimerId = setTimeout(() => {
typingTimerId = null;
_scheduleTypingTick();
- }, 22);
+ }, 14);
});
};
const setStreamText = (text) => {
@@ -2996,11 +3023,12 @@ ${autoLimit ? `auto-added claus
const appendToolSegment = (payload) => {
const p = payload && typeof payload === "object" ? payload : {};
const name = String(p.name || "tool");
+ const liveTag = currentLang === "zh" ? "[实时全量]" : "[LIVE FULL]";
const key = `${name}:${++toolSeq}`;
const rawPayload = p.payload != null ? p.payload : p;
- const body = formatToolPanelText(name, rawPayload);
+ const body = formatToolPanelText(name, rawPayload, { streamMode: true });
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 normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false });
@@ -3053,10 +3081,10 @@ ${autoLimit ? `auto-added claus
sessionId: activeId,
text: userText,
attachments: attachmentPayload,
- interactionMode: String(modeSelect.value || "comprehensive"),
+ interactionMode: String(modeSelect.value || "expert"),
idempotencyKey: String(turnId || ""),
specialist:
- String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
+ String(modeSelect.value || "expert").toLowerCase() === "expert"
? String(specialistSelect.value || "generalist")
: "generalist",
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
@@ -3144,6 +3172,11 @@ ${autoLimit ? `auto-added claus
chatStream = "";
chatRunId = null;
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 = "";
return;
}
@@ -3209,6 +3242,24 @@ ${autoLimit ? `auto-added claus
}, 350);
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.
// Poll history briefly before surfacing hard failure.
try {
diff --git a/oclaw.json b/oclaw.json
index 66e6d7a9..5d924f65 100644
--- a/oclaw.json
+++ b/oclaw.json
@@ -6,7 +6,7 @@
},
"entries": {
"memory-wiki": {
- "wiki_root": "oclaw/docs/memory-system/wiki",
+ "wiki_root": "data/wiki",
"max_search_results": 20,
"max_get_lines": 800,
"auto": {
@@ -20,7 +20,15 @@
"large_table_preview_rows": 20,
"tool_mode_enabled": true,
"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": {
diff --git a/platform/__init__.py b/platform/__init__.py
index e69de29b..ee187a23 100644
--- a/platform/__init__.py
+++ b/platform/__init__.py
@@ -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__ = []
+
diff --git a/platform/files/archive_processor.py b/platform/files/archive_processor.py
new file mode 100644
index 00000000..60a4d215
--- /dev/null
+++ b/platform/files/archive_processor.py
@@ -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",
+]
+
diff --git a/platform/files/file_attachments.py b/platform/files/file_attachments.py
index 65179fea..a9a8daeb 100644
--- a/platform/files/file_attachments.py
+++ b/platform/files/file_attachments.py
@@ -6,10 +6,10 @@ import io
import json
import os
import re
+import subprocess
import zipfile
from functools import lru_cache
from pathlib import Path
-from pathlib import PurePosixPath
from typing import Any
import pandas as pd
@@ -18,8 +18,24 @@ from PIL import Image
from PyPDF2 import PdfReader
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.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:
@@ -36,25 +52,101 @@ def _sniff_image_mime(data: bytes) -> str | 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
EXCEL_PREVIEW_ROWS = 80
LARGE_TABLE_PREVIEW_ROWS = 20
-MAX_ZIP_DEPTH = 2
-MAX_ZIP_FILE_COUNT = 200
-MAX_ZIP_ENTRY_BYTES = 10 * 1024 * 1024
-MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
+MAX_ZIP_DEPTH = MAX_ARCHIVE_DEPTH
+MAX_ZIP_FILE_COUNT = MAX_ARCHIVE_FILE_COUNT
+MAX_ZIP_ENTRY_BYTES = MAX_ARCHIVE_ENTRY_BYTES
+MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = MAX_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES
DEFAULT_TABULAR_ROWS_READ = 5000
DEFAULT_TABULAR_COLUMNS = 200
DEFAULT_TABULAR_CELL_CHARS = 500
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_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_ENTRY_BYTES = 20 * 1024 * 1024
MAX_EXCEL_ZIP_TOTAL_UNCOMPRESSED_BYTES = 120 * 1024 * 1024
DEFAULT_MAX_EXCEL_SHEETS = 50
+DEFAULT_TEXT_QUERY_TOP_K = 5
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]"
+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:
try:
return data.decode("utf-8")
@@ -120,6 +230,26 @@ def _attachments_limits() -> dict[str, Any]:
"tool_mode_max_bytes": _safe_int(
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()]
show_rows = max(1, int(preview_rows or EXCEL_PREVIEW_ROWS))
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 = (
f"# Table Summary\n"
f"- rows: {rows}\n"
@@ -169,7 +304,7 @@ def _dataframe_summary_text(
f"- clipped_cells: {'yes' if clipped_cells else 'no'}\n"
f"- columns: {', '.join(header)}\n"
f"- dtypes: {', '.join(dtypes)}\n\n"
- f"## Preview (first {min(rows, show_rows)} rows)\n"
+ f"{rows_scope_line}\n"
f"{sample}"
)
return _truncate_text(body)
@@ -191,19 +326,7 @@ def _pdf_summary_text(reader: PdfReader) -> str:
def _is_safe_zip_member_name(name: str) -> bool:
- n = str(name or "").replace("\\", "/").strip()
- 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
+ return is_safe_archive_member_name(name)
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]]:
- if _depth > MAX_ZIP_DEPTH:
- return [
- {
- "type": "text",
- "name": "zip-error",
- "content": f"ZIP nesting too deep (>{MAX_ZIP_DEPTH})",
- }
- ]
- attachments: list[dict[str, Any]] = []
- try:
- with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
- infos = [x for x in zf.infolist() if not x.is_dir()]
- 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
+ limits = _attachments_limits()
+ return process_archive(
+ archive_name="bundle.zip",
+ archive_data=zip_data,
+ process_member=lambda n, b, d: process_file_data(n, b, _zip_depth=d),
+ depth=_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
+ ),
+ },
+ )
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
+ 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"):
try:
if ext == "xlsx":
@@ -453,14 +610,97 @@ def process_file_data(name: str, data: bytes, *, _zip_depth: int = 0) -> list[di
try:
doc = Document(io.BytesIO(data))
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:
attachments.append({"type": "text", "name": name, "content": _truncate_text(f"Error parsing docx: {e}")})
elif ext == "pdf":
try:
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:
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)
if ext == "html":
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":
- attachments.extend(process_zip(data, _depth=_zip_depth))
+ elif detect_archive_kind(name):
+ 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
diff --git a/platform/files/tabular_attachment_store.py b/platform/files/tabular_attachment_store.py
index b1d9a407..dcc800fe 100644
--- a/platform/files/tabular_attachment_store.py
+++ b/platform/files/tabular_attachment_store.py
@@ -35,6 +35,11 @@ def _meta_path(table_id: str) -> Path:
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]]]:
out: list[str] = []
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:
- 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():
return None
try:
@@ -352,6 +360,29 @@ def _columns_for_sheet(meta: dict[str, Any], table_name: str) -> list[str]:
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(
*,
table_id: str,
@@ -361,6 +392,12 @@ def query_table(
where_contains: dict[str, str] | None = None,
sheet: str | None = None,
) -> 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)
if not isinstance(meta, dict):
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)
with sqlite3.connect(str(db)) as conn:
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 {
"ok": True,
"table_id": str(table_id),
@@ -404,6 +442,7 @@ def query_table(
"limit": lim,
"offset": off,
"engine": engine,
+ **({"sheet_hint": hint} if isinstance(hint, dict) else {}),
}
@@ -417,6 +456,12 @@ def aggregate_table(
top_n: int = 20,
sheet: str | None = None,
) -> 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)
if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"}
@@ -465,7 +510,7 @@ def aggregate_table(
"value": float(r.get("metric_value") or 0.0),
}
)
- return {
+ out = {
"ok": True,
"table_id": str(table_id),
"sheet": str(sheet or ""),
@@ -477,10 +522,14 @@ def aggregate_table(
"top_n": lim,
"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} '
raw_rows, engine = _run_select_sql(db_path=db, sql=sql, params=params)
row = raw_rows[0] if raw_rows else {}
- return {
+ out = {
"ok": True,
"table_id": str(table_id),
"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),
"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(
@@ -500,6 +553,12 @@ def run_table_sql(
limit: int = 200,
sheet: str | None = None,
) -> 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)
if not isinstance(meta, dict):
return {"ok": False, "error": "table_not_found"}
@@ -547,7 +606,7 @@ def run_table_sql(
},
}
out_rows = rows[:lim]
- return {
+ out = {
"ok": True,
"table_id": str(table_id),
"name": str(meta.get("name") or ""),
@@ -568,6 +627,10 @@ def run_table_sql(
"limit": lim,
"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(
@@ -577,6 +640,12 @@ def analyze_table_full_scan(
sheet: str | None = None,
top_values_limit: int = 3,
) -> 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)
if not isinstance(meta, dict):
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]
col_stats.append(item)
elapsed_ms = int((time.perf_counter() - started) * 1000)
- return {
+ out = {
"ok": True,
"table_id": str(table_id),
"name": str(meta.get("name") or ""),
@@ -645,6 +714,10 @@ def analyze_table_full_scan(
},
"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__ = [
diff --git a/platform/files/text_attachment_store.py b/platform/files/text_attachment_store.py
new file mode 100644
index 00000000..9a7316af
--- /dev/null
+++ b/platform/files/text_attachment_store.py
@@ -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",
+]
+
diff --git a/runtime/chat/agent_messages.py b/runtime/chat/agent_messages.py
index 1e0663bb..1397f224 100644
--- a/runtime/chat/agent_messages.py
+++ b/runtime/chat/agent_messages.py
@@ -177,6 +177,9 @@ def _summarize_unpaired_tool_content(raw: str, *, cap: int) -> 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()
if not 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":
p_uri = str(att.get("pointer_uri") or "").strip()
if not p_uri:
@@ -471,7 +507,7 @@ def build_llm_messages(
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n..."
except Exception:
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n..."
- 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.
if "_tool_result_guarded" not in str(tool_content_out or ""):
tool_content_out = _truncate_tool_context(tool_content_out, lang=lang)
@@ -494,8 +530,7 @@ def build_llm_messages(
r = getattr(m, "content", "") or ""
cap2 = tool_llm_message_max_chars()
pretty2 = _summarize_unpaired_tool_content(r, cap=cap2)
- if tool_context_truncate_enabled:
- pretty2 = _truncate_tool_context(pretty2, lang=lang)
+ # Unpaired tool rows are already summarized; avoid extra 50-char clipping.
out.append(
{
"role": "assistant",
diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py
index aedf5f57..63f72aa4 100644
--- a/runtime/chat/tool_runtime.py
+++ b/runtime/chat/tool_runtime.py
@@ -11,6 +11,7 @@ import logging
import time
import os
import threading
+from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import TimeoutError as FuturesTimeoutError
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.platform.llm.chat_models import LLMToolCall
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__)
_tool_exec_log = logging.getLogger("oclaw.tool_exec")
@@ -33,6 +37,170 @@ _SQL_REPLAY_COMPACT_TOOL_NAMES = {
"run_tabular_sql",
"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]:
@@ -206,6 +374,7 @@ class ToolExecutionContext:
#: 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_user_id: str | None = None
+ workspace_dir: str | None = None
turn_uuid: str | None = None
@@ -235,13 +404,21 @@ class ToolExecutor:
timeout_s = 30.0
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(
ctx.store,
ctx.session_id,
owner_fallback_session_id=ctx.workspace_owner_session_id,
allowlist_tenant_id=ctx.path_policy_tenant_id,
allowlist_user_id=ctx.path_policy_user_id,
- ):
+ ), workspace_write_namespace_scope(ws_ns):
return tool.handler(tc.arguments)
if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0:
@@ -400,65 +577,98 @@ class ToolExecutor:
)
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()
if not tool_uses:
return [], {}
- history_summary_threshold = int(tool_history_summary_after_calls())
turn_tool_name_counts, turn_tool_observed_rows = _load_turn_tool_stats()
local_turn_tool_name_counts: 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]] = {}
runnable_tool_uses: list[LLMToolCall] = []
sig_seen: dict[str, int] = {}
budget = max(1, min(int(signature_budget or 2), 8))
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 {}))}"
count = int(sig_seen.get(sig, 0))
if count >= budget:
@@ -539,27 +749,14 @@ class ToolExecutor:
duration_ms=duration_ms,
)
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()
observed_rows_this_call = int(_estimate_observed_rows(result))
- result_for_llm = truncate_tool_result_for_llm_messages(result)
- should_compact_history = 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))
+ result_for_llm = dict(result or {})
+ if tc.name in _SQL_REPLAY_COMPACT_TOOL_NAMES:
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))
- 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_observed_rows[tc.name] = current_rows + observed_rows_this_call
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_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(
"tool_result",
{
@@ -650,6 +806,57 @@ class ToolExecutor:
on_tool_ui("tool_use_result", payload)
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__ = [
"ToolExecutionConfig",
"ToolExecutionContext",
@@ -658,4 +865,5 @@ __all__ = [
"partition_tool_use_batches",
"tool_llm_message_max_chars",
"truncate_tool_result_for_llm_messages",
+ "compact_turn_tool_messages_for_storage",
]
diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py
index 2dde9200..a22706b8 100644
--- a/runtime/direct_loop.py
+++ b/runtime/direct_loop.py
@@ -8,6 +8,7 @@ import uuid
import copy
import threading
from dataclasses import dataclass
+from pathlib import Path
from types import SimpleNamespace
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
_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] = {
"tool_wire_filter": "wire_filter",
@@ -39,6 +42,82 @@ _TOOL_WIRE_LAST_WARM_ROLES: tuple[str, ...] = ()
_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:
raw = ""
try:
@@ -218,6 +297,7 @@ def _guard_tool_results_for_llm_context(
run_id: str | None = None,
attempt_no: int | None = None,
lang: str = "",
+ active_turn_uuid: str | None = None,
) -> list[Any]:
"""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.
"""
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] = []
for m in store_messages or []:
role = str(getattr(m, "role", "") or "")
if role != "tool":
out.append(m)
continue
- raw = str(getattr(m, "content", "") or "")
- if len(raw) <= cap:
+ if str(getattr(m, "turn_uuid", "") or "") == str(active_turn_uuid or "") and str(active_turn_uuid or "").strip():
out.append(m)
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
error_code = ""
error = ""
+ obj: dict[str, Any] | None = None
try:
- obj = json.loads(raw)
- if isinstance(obj, dict):
+ parsed = json.loads(raw)
+ if isinstance(parsed, dict):
+ obj = parsed
ok = obj.get("ok")
error_code = str(obj.get("error_code") or "").strip()
error = str(obj.get("error") or "").strip()
except Exception:
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..."
+ 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..."
+ 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..."
guarded_obj = {
"ok": bool(ok) if ok is not None else None,
@@ -294,6 +433,118 @@ def _guard_tool_results_for_llm_context(
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_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_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:
if should_stop and should_stop():
raise RuntimeError("generation interrupted by user")
@@ -318,6 +569,7 @@ def _build_model_context(
skill_binding_role: str | None = None,
user_text: str = "",
prompt_build_context: dict[str, Any] | None = None,
+ active_turn_uuid: str | None = None,
) -> list[dict[str, Any]]:
rows = store.get_messages(session_id=session_id, limit=int(max_messages))
rows = _guard_tool_results_for_llm_context(
@@ -330,6 +582,12 @@ def _build_model_context(
run_id=run_id,
attempt_no=attempt_no,
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(
store=store,
@@ -672,10 +930,11 @@ def run_oclaw_direct_loop(
skill_binding_role: str | None = None,
wire_policy_role: str | None = None,
prompt_build_context: dict[str, Any] | None = None,
+ turn_uuid: str | None = None,
) -> TurnRunOutcome:
"""A minimal oclaw-style loop: model -> tool_uses -> execute -> tool_results -> continue."""
_check_stop(should_stop)
- turn_uuid = str(uuid.uuid4())
+ turn_uuid = str(turn_uuid or "").strip() or str(uuid.uuid4())
if persist_user_message:
store.add_message(
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))))
tool_traces: list[dict[str, Any]] = []
final_text = ""
+ hit_tool_round_limit = False
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)
if on_progress:
on_progress(f"oclaw: think ({round_idx + 1})…")
@@ -715,6 +976,7 @@ def run_oclaw_direct_loop(
skill_binding_role=skill_binding_role,
user_text=str(user_text or ""),
prompt_build_context=prompt_build_context,
+ active_turn_uuid=turn_uuid,
)
llm_tools = _prepare_llm_tools(
store=store,
@@ -744,6 +1006,10 @@ def run_oclaw_direct_loop(
final_text = step.assistant_text
if not step.llm_tool_calls:
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(
skill_exec=skill_exec,
@@ -783,6 +1049,42 @@ def run_oclaw_direct_loop(
if on_progress:
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(
final_text=str(final_text or ""),
tool_traces=tuple(tool_traces),
diff --git a/runtime/gateway.py b/runtime/gateway.py
index c8d4b9ed..b1a3628d 100644
--- a/runtime/gateway.py
+++ b/runtime/gateway.py
@@ -34,6 +34,7 @@ from oclaw.runtime.memory_stage import after_turn_memory
from oclaw.runtime.router import decide_route
from oclaw.runtime.worker import ensure_worker_started
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] = {
"gateway_received": "ingress",
@@ -91,6 +92,7 @@ class OclawGateway:
start = t.find("{")
if start < 0:
return None
+ executed_turn_uuid = ""
try:
obj, _end = json.JSONDecoder().raw_decode(t[start:])
except Exception:
@@ -414,6 +416,36 @@ class OclawGateway:
return True
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
def _tabular_query_system_hint(lang: str) -> str:
limits = OclawGateway._tabular_limits_from_config()
@@ -431,6 +463,38 @@ class OclawGateway:
"如果需要更多行或更细节,请通过数据库工具(`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
def _tabular_limits_from_config() -> dict[str, int]:
cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
@@ -767,6 +831,25 @@ class OclawGateway:
},
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.
specialist_input_msg = StandardMessage(
session_id=msg.session_id,
@@ -984,6 +1067,12 @@ class OclawGateway:
sys_prompt = str(getattr(selected_executor, "system_prompt", "") or "")
if self._has_tabular_ref_attachments(msg):
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:
try:
@@ -1042,6 +1131,7 @@ class OclawGateway:
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 "")
if manager_memory_mode:
# manager_memory: write memory silently, but keep dialog output independent.
@@ -1095,6 +1185,15 @@ class OclawGateway:
)
except Exception:
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(
event_type="response_sent",
payload={"ok": bool(str(reply or "").strip()), "elapsed_ms": elapsed_ms, "mode": "sync_direct"},
diff --git a/runtime/tools/experts/generalist/image_query.py b/runtime/tools/experts/generalist/image_query.py
new file mode 100644
index 00000000..8fbdae04
--- /dev/null
+++ b/runtime/tools/experts/generalist/image_query.py
@@ -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"]
+
diff --git a/runtime/tools/experts/generalist/text_query.py b/runtime/tools/experts/generalist/text_query.py
new file mode 100644
index 00000000..a8db181f
--- /dev/null
+++ b/runtime/tools/experts/generalist/text_query.py
@@ -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"]
+
diff --git a/runtime/tools/experts/generalist/video_query.py b/runtime/tools/experts/generalist/video_query.py
new file mode 100644
index 00000000..f75562de
--- /dev/null
+++ b/runtime/tools/experts/generalist/video_query.py
@@ -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"]
+
diff --git a/runtime/tools/experts/network_ops/image_query.py b/runtime/tools/experts/network_ops/image_query.py
new file mode 100644
index 00000000..82624276
--- /dev/null
+++ b/runtime/tools/experts/network_ops/image_query.py
@@ -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"]
+
diff --git a/runtime/tools/experts/network_ops/text_query.py b/runtime/tools/experts/network_ops/text_query.py
new file mode 100644
index 00000000..264a11ee
--- /dev/null
+++ b/runtime/tools/experts/network_ops/text_query.py
@@ -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"]
+
diff --git a/runtime/tools/experts/network_ops/video_query.py b/runtime/tools/experts/network_ops/video_query.py
new file mode 100644
index 00000000..2dbf6d55
--- /dev/null
+++ b/runtime/tools/experts/network_ops/video_query.py
@@ -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"]
+
diff --git a/tests/test_attachment_text_replay_guard.py b/tests/test_attachment_text_replay_guard.py
new file mode 100644
index 00000000..23e2086f
--- /dev/null
+++ b/tests/test_attachment_text_replay_guard.py
@@ -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
+
diff --git a/tests/test_file_attachments.py b/tests/test_file_attachments.py
index 212ebb5c..19ba813e 100644
--- a/tests/test_file_attachments.py
+++ b/tests/test_file_attachments.py
@@ -2,6 +2,8 @@ from __future__ import annotations
import io
import json
+import gzip
+import tarfile
import zipfile
from pathlib import Path
@@ -15,6 +17,7 @@ from oclaw.platform.files.tabular_attachment_store import (
run_table_sql,
save_workbook,
)
+from oclaw.platform.files.text_attachment_store import query_text_document
def _zip_bytes(files: dict[str, bytes]) -> bytes:
@@ -25,6 +28,17 @@ def _zip_bytes(files: dict[str, bytes]) -> bytes:
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:
payload = (
"c1,c2,c3\n"
@@ -37,6 +51,32 @@ def test_csv_is_summarized_not_full_dump() -> None:
assert "# Table Summary" in content
assert "rows: 2" 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:
@@ -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()
+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:
z4 = _zip_bytes({"deep.txt": b"hello"})
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"}
+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:
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")
@@ -324,6 +470,20 @@ def test_query_can_target_specific_excel_sheet() -> None:
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:
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")
@@ -345,6 +505,25 @@ def test_full_scan_analyzes_all_rows_and_returns_audit() -> None:
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:
payload = _zip_bytes({"../xl/workbook.xml": b"x"})
out = process_file_data("bad.xlsx", payload)
diff --git a/tests/test_tool_loop_guard.py b/tests/test_tool_loop_guard.py
index 23e8f039..beb90562 100644
--- a/tests/test_tool_loop_guard.py
+++ b/tests/test_tool_loop_guard.py
@@ -123,3 +123,272 @@ def test_repeated_non_sql_tools_are_not_compacted(tmp_path: Path) -> None:
assert 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
+
diff --git a/tests/test_video_query_tool.py b/tests/test_video_query_tool.py
new file mode 100644
index 00000000..7e0fec96
--- /dev/null
+++ b/tests/test_video_query_tool.py
@@ -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
+