mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 18:35:48 +08:00
feat: multimodal image clients, workspaces, Tushare skill, test fixes
- Replace monolithic image_message_client with HTTP/OCR/legacy modules; tighten OpenAI transport + tool schemas for multimodal downgrade to OCR specialist path. - Add image/stock workspace prompts (META/SOUL/ROLE_SYSTEM); register experts; tweak specialist agent/direct loop/query_image_attachment. - Add bundled runtime/skills/tushare-finance (references, api_client, SKILL metadata). - Document OCR-related env vars; admin chat tweaks; README; weixin_install Ensure-OfficialPluginRuntimeDeps helper. - Tests: multimodal downgrade + OCR coverage, strict tool pairing in attachment replay guard, workspace contract skips _internal/_system dirs, router/trace/prompt guards. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6307ade480
commit
d1bcc4debe
266 changed files with 57024 additions and 839 deletions
|
|
@ -79,7 +79,19 @@ def test_large_image_tool_result_is_guarded_in_history_context(tmp_path: Path) -
|
|||
"text": long_text,
|
||||
"backend_shape": "multi",
|
||||
}
|
||||
store.add_message(session_id=sess.id, role="tool", content=json.dumps(payload, ensure_ascii=False))
|
||||
tc_id = "c_img_hist_guard"
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": tc_id, "name": "query_image_attachment", "arguments": {}}],
|
||||
)
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="tool",
|
||||
content=json.dumps(payload, ensure_ascii=False),
|
||||
tool_calls={"tool_call_id": tc_id, "name": "query_image_attachment"},
|
||||
)
|
||||
|
||||
msgs = _build_model_context(
|
||||
store=store,
|
||||
|
|
@ -111,7 +123,19 @@ def test_small_image_tool_result_not_guarded(tmp_path: Path) -> None:
|
|||
"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))
|
||||
tc_id = "c_img_small_ok"
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": tc_id, "name": "query_image_attachment", "arguments": {}}],
|
||||
)
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="tool",
|
||||
content=json.dumps(payload, ensure_ascii=False),
|
||||
tool_calls={"tool_call_id": tc_id, "name": "query_image_attachment"},
|
||||
)
|
||||
|
||||
msgs = _build_model_context(
|
||||
store=store,
|
||||
|
|
@ -143,7 +167,19 @@ def test_large_video_transcript_tool_result_is_guarded_in_history_context(tmp_pa
|
|||
"attachment_id": "d" * 64,
|
||||
"text": long_text,
|
||||
}
|
||||
store.add_message(session_id=sess.id, role="tool", content=json.dumps(payload, ensure_ascii=False))
|
||||
tc_id = "c_vid_hist_guard"
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": tc_id, "name": "query_video_attachment", "arguments": {}}],
|
||||
)
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="tool",
|
||||
content=json.dumps(payload, ensure_ascii=False),
|
||||
tool_calls={"tool_call_id": tc_id, "name": "query_video_attachment"},
|
||||
)
|
||||
|
||||
msgs = _build_model_context(
|
||||
store=store,
|
||||
|
|
|
|||
|
|
@ -416,8 +416,8 @@ def test_gateway_comprehensive_mode_manager_first_selects_specialist(monkeypatch
|
|||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory"',
|
||||
"allowed_fixed": ("generalist", "ops", "memory", "image"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory", "image"',
|
||||
},
|
||||
)
|
||||
captured: dict = {}
|
||||
|
|
@ -485,8 +485,8 @@ def test_gateway_comprehensive_mode_writes_task_assignment_reasoning(monkeypatch
|
|||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory"',
|
||||
"allowed_fixed": ("generalist", "ops", "memory", "image"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory", "image"',
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -544,8 +544,8 @@ def test_gateway_comprehensive_ignores_wiki_inject_flags(monkeypatch: pytest.Mon
|
|||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory"',
|
||||
"allowed_fixed": ("generalist", "ops", "memory", "image"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory", "image"',
|
||||
},
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
|
@ -607,8 +607,8 @@ def test_gateway_comprehensive_mode_has_manager_final_pass(monkeypatch: pytest.M
|
|||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory"',
|
||||
"allowed_fixed": ("generalist", "ops", "memory", "image"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory", "image"',
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -663,8 +663,8 @@ def test_gateway_comprehensive_mode_dynamic_agent_dispatches_instruction_only(mo
|
|||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory"',
|
||||
"allowed_fixed": ("generalist", "ops", "memory", "image"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory", "image"',
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("oclaw.runtime.gateway.build_ephemeral_executor", lambda *args, **kwargs: _Exec(model=object()))
|
||||
|
|
@ -726,8 +726,8 @@ def test_gateway_comprehensive_mode_ignores_manager_self_and_dispatches_speciali
|
|||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory"',
|
||||
"allowed_fixed": ("generalist", "ops", "memory", "image"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory", "image"',
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -803,8 +803,8 @@ def test_gateway_comprehensive_mode_suppresses_instruction_echo(monkeypatch: pyt
|
|||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory"',
|
||||
"allowed_fixed": ("generalist", "ops", "memory", "image"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "memory", "image"',
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,10 @@ def test_interaction_mode_normalization_supports_legacy_values() -> None:
|
|||
|
||||
def test_requested_specialist_normalization_defaults_to_generalist() -> None:
|
||||
assert normalize_requested_specialist("ops") == "ops"
|
||||
assert normalize_requested_specialist("image") == "generalist"
|
||||
# "stock" is a dynamic specialist discovered from runtime workspaces.
|
||||
assert normalize_requested_specialist("stock") == "stock"
|
||||
assert normalize_requested_specialist("image") == "image"
|
||||
# Unknown ids fall back to generalist.
|
||||
assert normalize_requested_specialist("memory") == "memory"
|
||||
assert normalize_requested_specialist("unknown") == "generalist"
|
||||
|
||||
|
|
|
|||
45
tests/test_openai_chat_multimodal_downgrade.py
Normal file
45
tests/test_openai_chat_multimodal_downgrade.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.platform.llm.transports.openai_chat_completions import (
|
||||
_is_text_only_gateway_error,
|
||||
_messages_contain_list_with_image,
|
||||
_should_proactively_downgrade_multimodal_messages,
|
||||
_wire_error_message,
|
||||
)
|
||||
|
||||
|
||||
def test_text_only_gateway_error_matches_deepseek_deserialize() -> None:
|
||||
msg = "messages[1]: unknown variant image_url, expected text at line 1 column 74402"
|
||||
assert _is_text_only_gateway_error(msg)
|
||||
|
||||
|
||||
def test_text_only_gateway_error_from_nested_body() -> None:
|
||||
snippet = '{"error":{"message":"Failed to deserialize: unknown variant image_url, expected text"}}'
|
||||
assert _is_text_only_gateway_error(snippet)
|
||||
|
||||
|
||||
def test_messages_contain_list_with_image() -> None:
|
||||
msgs = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hi"}, {"type": "image_url", "image_url": {"url": "x"}}]},
|
||||
]
|
||||
assert _messages_contain_list_with_image(msgs)
|
||||
assert _should_proactively_downgrade_multimodal_messages(msgs, model="deepseek-chat", base_url="https://api.example.com/")
|
||||
assert not _should_proactively_downgrade_multimodal_messages(
|
||||
msgs, model="gpt-4o-mini", base_url="https://api.openai.com/v1/"
|
||||
)
|
||||
|
||||
|
||||
class _Bare400(Exception):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Error code: 400")
|
||||
self.body = {
|
||||
"error": {
|
||||
"message": "Failed to deserialize: messages[1]: unknown variant image_url, expected text",
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_wire_error_message_includes_exception_body_json() -> None:
|
||||
wired = _wire_error_message(_Bare400())
|
||||
assert _is_text_only_gateway_error(wired)
|
||||
66
tests/test_openai_chat_multimodal_downgrade_ocr.py
Normal file
66
tests/test_openai_chat_multimodal_downgrade_ocr.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from oclaw.platform.llm.transports.openai_chat_completions import (
|
||||
_flatten_message_content_for_text_gateway,
|
||||
_normalize_messages_for_text_only_gateway,
|
||||
)
|
||||
|
||||
|
||||
def test_text_only_downgrade_injects_ocr_when_vision_lane_ok(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIA_MULTIMODAL_DOWNGRADE_OCR", "1")
|
||||
monkeypatch.setenv("AIA_OCR_BASE_URL", "https://ocr.example/v1")
|
||||
monkeypatch.setenv("AIA_OCR_API_KEY", "k")
|
||||
monkeypatch.setenv("AIA_OCR_MODEL", "vl-test")
|
||||
|
||||
def fake_send(*, images, prompt, **kwargs):
|
||||
_ = (images, prompt, kwargs)
|
||||
return {"ok": True, "text": "HELLO_FROM_PIC"}
|
||||
|
||||
with patch("oclaw.platform.llm.image_ocr_client.send_ocr_image_messages", fake_send):
|
||||
content = [
|
||||
{"type": "text", "text": "请看图"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QUJD"}},
|
||||
]
|
||||
flat = _flatten_message_content_for_text_gateway(content)
|
||||
assert "HELLO_FROM_PIC" in flat
|
||||
assert "【图片内容·OCR】" in flat
|
||||
assert "纯文本" in flat
|
||||
assert "请看图" in flat
|
||||
assert "无法直接读图" in flat
|
||||
|
||||
|
||||
def test_text_only_downgrade_falls_back_when_ocr_disabled(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIA_MULTIMODAL_DOWNGRADE_OCR", "0")
|
||||
monkeypatch.setenv("AIA_OCR_BASE_URL", "https://ocr.example/v1")
|
||||
monkeypatch.setenv("AIA_OCR_API_KEY", "k")
|
||||
monkeypatch.setenv("AIA_OCR_MODEL", "vl-test")
|
||||
|
||||
content = [
|
||||
{"type": "text", "text": "hi"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QUJD"}},
|
||||
]
|
||||
flat = _flatten_message_content_for_text_gateway(content)
|
||||
assert "【图片】本消息含图片" in flat
|
||||
assert "hi" in flat
|
||||
|
||||
|
||||
def test_normalize_messages_uses_ocr_flatten(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIA_MULTIMODAL_DOWNGRADE_OCR", "1")
|
||||
monkeypatch.setenv("AIA_OCR_BASE_URL", "https://ocr.example/v1")
|
||||
monkeypatch.setenv("AIA_OCR_API_KEY", "k")
|
||||
monkeypatch.setenv("AIA_OCR_MODEL", "vl-test")
|
||||
|
||||
def fake_send(*, images, prompt, **kwargs):
|
||||
_ = (images, prompt, kwargs)
|
||||
return {"ok": True, "text": "X"}
|
||||
|
||||
with patch("oclaw.platform.llm.image_ocr_client.send_ocr_image_messages", fake_send):
|
||||
out = _normalize_messages_for_text_only_gateway(
|
||||
[{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://x/y.png"}}]}]
|
||||
)
|
||||
assert len(out) == 1
|
||||
assert out[0]["role"] == "user"
|
||||
assert "X" in str(out[0]["content"])
|
||||
assert isinstance(out[0]["content"], str)
|
||||
149
tests/test_openai_tools_schema_compat.py
Normal file
149
tests/test_openai_tools_schema_compat.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.platform.llm.tool_schema import complete_openai_tools_wire_parameters
|
||||
|
||||
|
||||
def test_complete_tools_coerces_null_function_parameters_to_min_object_schema() -> None:
|
||||
raw = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "mcp__mcp-playwright__browser_click",
|
||||
"description": "click",
|
||||
"parameters": None,
|
||||
},
|
||||
}
|
||||
]
|
||||
out = complete_openai_tools_wire_parameters(raw)
|
||||
assert len(out) == 1
|
||||
fn = out[0]["function"]
|
||||
params = fn["parameters"]
|
||||
assert isinstance(params, dict)
|
||||
assert params.get("type") == "object"
|
||||
assert params.get("required") == []
|
||||
assert params.get("properties") == {}
|
||||
assert "additionalProperties" in params
|
||||
|
||||
|
||||
def test_complete_coerces_array_items_null_to_empty_object() -> None:
|
||||
raw = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "mcp__tusharemcp__adj_factor",
|
||||
"description": "x",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codes": {"type": "array", "items": None, "description": "ids"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
out = complete_openai_tools_wire_parameters(raw)
|
||||
codes = out[0]["function"]["parameters"]["properties"]["codes"]
|
||||
assert codes.get("type") == "array"
|
||||
assert codes.get("items") == {}
|
||||
|
||||
|
||||
def test_complete_drops_null_combo_keywords_and_fixes_lists() -> None:
|
||||
raw = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "demo",
|
||||
"description": "d",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"anyOf": None, "description": "a"},
|
||||
"y": {"allOf": None},
|
||||
"z": {"prefixItems": None},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
out = complete_openai_tools_wire_parameters(raw)
|
||||
props = out[0]["function"]["parameters"]["properties"]
|
||||
assert "anyOf" not in props["x"]
|
||||
assert props["y"].get("allOf") == []
|
||||
assert props["z"].get("prefixItems") == []
|
||||
|
||||
|
||||
def test_complete_strips_null_entries_from_combo_arrays() -> None:
|
||||
raw = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "demo2",
|
||||
"description": "d",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"anyOf": [None, {"type": "string"}]},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
out = complete_openai_tools_wire_parameters(raw)
|
||||
assert out[0]["function"]["parameters"]["properties"]["x"]["anyOf"] == [{"type": "string"}]
|
||||
|
||||
|
||||
def test_complete_fills_minimal_object_parameters_like_tier_minimal() -> None:
|
||||
raw = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "mcp__srv__ping",
|
||||
"description": "p",
|
||||
"parameters": {"type": "object", "additionalProperties": True},
|
||||
},
|
||||
}
|
||||
]
|
||||
out = complete_openai_tools_wire_parameters(raw)
|
||||
params = out[0]["function"]["parameters"]
|
||||
assert params.get("type") == "object"
|
||||
assert params.get("required") == []
|
||||
assert params.get("properties") == {}
|
||||
|
||||
|
||||
def test_complete_fills_object_type_when_null_with_properties() -> None:
|
||||
raw = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "x",
|
||||
"description": "",
|
||||
"parameters": {"type": None, "properties": {"a": {"type": "string"}}, "required": []},
|
||||
},
|
||||
}
|
||||
]
|
||||
out = complete_openai_tools_wire_parameters(raw)
|
||||
params = out[0]["function"]["parameters"]
|
||||
assert params.get("type") == "object"
|
||||
assert "a" in params.get("properties", {})
|
||||
|
||||
|
||||
def test_complete_removes_empty_anyof_after_null_strip() -> None:
|
||||
raw = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "demo3",
|
||||
"description": "d",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"x": {"anyOf": [None]}},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
out = complete_openai_tools_wire_parameters(raw)
|
||||
assert "anyOf" not in out[0]["function"]["parameters"]["properties"]["x"]
|
||||
|
|
@ -13,7 +13,8 @@ _PROMPT_CRITICAL_FILES = (
|
|||
"runtime/memory_stage.py",
|
||||
"runtime/project_context_prompt.py",
|
||||
"runtime/chat/agent_errors.py",
|
||||
"platform/llm/image_message_client.py",
|
||||
"platform/llm/image_ocr_client.py",
|
||||
"platform/llm/image_legacy_client.py",
|
||||
"runtime/gateway.py",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class _DummyStore:
|
|||
|
||||
|
||||
def test_get_manager_prompt_prebuild_includes_structured_skills(monkeypatch) -> None:
|
||||
monkeypatch.setattr(pp, "discover_specialist_ids", lambda: ("generalist", "ops", "memory"))
|
||||
monkeypatch.setattr(pp, "discover_specialist_ids", lambda: ("generalist", "ops", "memory", "image"))
|
||||
monkeypatch.setattr(
|
||||
pp,
|
||||
"list_experts",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ def test_every_workspace_has_role_system() -> None:
|
|||
continue
|
||||
if item.name.startswith("__"):
|
||||
continue
|
||||
# `_system` holds template fragments/snippets, not routed specialist workspaces.
|
||||
if item.name.startswith("_"):
|
||||
continue
|
||||
assert (item / "ROLE_SYSTEM.md").exists(), f"missing ROLE_SYSTEM.md for {item.name}"
|
||||
|
||||
|
||||
|
|
@ -49,5 +52,7 @@ def test_workspace_legacy_prompt_files_removed() -> None:
|
|||
continue
|
||||
if item.name.startswith("__"):
|
||||
continue
|
||||
if item.name.startswith("_"):
|
||||
continue
|
||||
for name in legacy:
|
||||
assert not (item / name).exists(), f"legacy prompt file still exists: {item.name}/{name}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue