mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
补充并收敛管理台与运行时改动,完成本轮代码提交。
包含模型配置/thinking 透传、MCP 运行时与安装体验增强、以及会话渲染与图片显示链路修复,确保流式与历史展示行为一致。 Made-with: Cursor
This commit is contained in:
parent
d57969d66d
commit
3d27a01879
10 changed files with 395 additions and 41 deletions
|
|
@ -90,6 +90,24 @@ def _build_executor_components(
|
|||
m = (raw or "").strip().lower()
|
||||
return m if m in ("openai", "openai_responses", "anthropic", "ollama", "rule", "google") else "rule"
|
||||
|
||||
def _profile_thinking_config(profile: dict[str, Any] | None) -> tuple[bool, str]:
|
||||
if not isinstance(profile, dict):
|
||||
return False, ""
|
||||
think = bool(profile.get("thinking_mode_enabled"))
|
||||
eff = str(profile.get("reasoning_effort") or "").strip().lower()
|
||||
if eff not in ("", "low", "medium", "high"):
|
||||
eff = ""
|
||||
return think, eff
|
||||
|
||||
def _apply_profile_thinking(model_obj: object, profile: dict[str, Any] | None) -> object:
|
||||
think, eff = _profile_thinking_config(profile)
|
||||
try:
|
||||
setattr(model_obj, "thinking_mode_enabled", think)
|
||||
setattr(model_obj, "reasoning_effort", eff)
|
||||
except Exception:
|
||||
pass
|
||||
return model_obj
|
||||
|
||||
def _build_chat_model_for_profile(
|
||||
target_profile_id: str | None,
|
||||
*,
|
||||
|
|
@ -123,7 +141,7 @@ def _build_executor_components(
|
|||
if mode == "openai_responses":
|
||||
if not api_key:
|
||||
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
|
||||
return OpenAIResponsesModel(model=model_name, api_key=api_key, base_url=bu or None), mode
|
||||
return _apply_profile_thinking(OpenAIResponsesModel(model=model_name, api_key=api_key, base_url=bu or None), profile), mode
|
||||
if mode == "anthropic":
|
||||
akey = (
|
||||
(openai_api_key if allow_runtime_overrides else None)
|
||||
|
|
@ -155,10 +173,10 @@ def _build_executor_components(
|
|||
if mode == "ollama":
|
||||
ollama_base = (bu or DEFAULT_OLLAMA_BASE_URL).strip() or DEFAULT_OLLAMA_BASE_URL
|
||||
ollama_key = api_key or _OLLAMA_DUMMY_KEY
|
||||
return OpenAIChatModel(model=model_name, api_key=ollama_key, base_url=ollama_base), mode
|
||||
return _apply_profile_thinking(OpenAIChatModel(model=model_name, api_key=ollama_key, base_url=ollama_base), profile), mode
|
||||
if not api_key:
|
||||
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
|
||||
return OpenAIChatModel(model=model_name, api_key=api_key, base_url=bu or None), mode
|
||||
return _apply_profile_thinking(OpenAIChatModel(model=model_name, api_key=api_key, base_url=bu or None), profile), mode
|
||||
|
||||
valid_profile_ids = {p["id"] for p in store.list_llm_profiles(visible_only=True, **list_kw)}
|
||||
if active_pid and active_pid not in valid_profile_ids:
|
||||
|
|
|
|||
|
|
@ -199,7 +199,28 @@ def build_llm_messages(
|
|||
) -> list[dict[str, Any]]:
|
||||
"""把 DB 中的消息序列转换为 LLM messages。"""
|
||||
out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}]
|
||||
thinking_mode_enabled = bool(getattr(model, "thinking_mode_enabled", False))
|
||||
allow_signature_replay = _allow_reasoning_signature_replay(model)
|
||||
reasoning_by_turn: dict[str, list[tuple[int, str]]] = {}
|
||||
if thinking_mode_enabled:
|
||||
for m in store_messages or []:
|
||||
if str(getattr(m, "role", "") or "") != "assistant":
|
||||
continue
|
||||
if str(getattr(m, "event_type", "") or "").strip().lower() != "reasoning":
|
||||
continue
|
||||
tid = str(getattr(m, "turn_uuid", "") or "").strip()
|
||||
if not tid:
|
||||
continue
|
||||
try:
|
||||
idx = 0
|
||||
ep = getattr(m, "event_payload", None)
|
||||
if isinstance(ep, str) and ep.strip():
|
||||
payload = json.loads(ep)
|
||||
if isinstance(payload, dict):
|
||||
idx = int(payload.get("chunk_index") or 0)
|
||||
except Exception:
|
||||
idx = 0
|
||||
reasoning_by_turn.setdefault(tid, []).append((idx, str(getattr(m, "content", "") or "")))
|
||||
historical_tool_ids = _collect_historical_tool_call_ids(
|
||||
store_messages=store_messages, full_rounds=_replay_recent_tool_rounds()
|
||||
)
|
||||
|
|
@ -207,7 +228,46 @@ def build_llm_messages(
|
|||
# that is not present in the assistant tool_calls within the same request context.
|
||||
# This can happen when context windows are trimmed and the assistant tool_calls row is dropped.
|
||||
valid_tool_call_ids: set[str] = set()
|
||||
for m in store_messages:
|
||||
# Precompute tool_call_id suffix sets to detect broken tool_calls -> tool pairing.
|
||||
tool_ids_after: list[set[str]] = []
|
||||
seen_tool_ids: set[str] = set()
|
||||
for m in reversed(store_messages or []):
|
||||
role = str(getattr(m, "role", "") or "")
|
||||
if role == "tool":
|
||||
tcid = _tool_call_id_from_tool_row(getattr(m, "tool_calls", None))
|
||||
if tcid:
|
||||
seen_tool_ids.add(tcid)
|
||||
tool_ids_after.append(set(seen_tool_ids))
|
||||
tool_ids_after.reverse()
|
||||
def _attach_reasoning_content(row: dict[str, Any], m: Any) -> dict[str, Any]:
|
||||
if not thinking_mode_enabled:
|
||||
return row
|
||||
rc = ""
|
||||
ep = getattr(m, "event_payload", None)
|
||||
if isinstance(ep, str) and ep.strip():
|
||||
try:
|
||||
payload = json.loads(ep)
|
||||
if isinstance(payload, dict):
|
||||
rc = str(payload.get("reasoning_content") or "").strip()
|
||||
except Exception:
|
||||
rc = ""
|
||||
if not rc:
|
||||
tid = str(getattr(m, "turn_uuid", "") or "").strip()
|
||||
chunks = reasoning_by_turn.get(tid) or []
|
||||
if chunks:
|
||||
rc = "\n".join(
|
||||
[
|
||||
str(x[1] or "").strip()
|
||||
for x in sorted(chunks, key=lambda t: int(t[0] or 0))
|
||||
if str(x[1] or "").strip()
|
||||
]
|
||||
).strip()
|
||||
# Provider contract: in thinking mode, the field must be present for every assistant message,
|
||||
# even if empty (some gateways error on missing key).
|
||||
row["reasoning_content"] = rc
|
||||
return row
|
||||
|
||||
for i, m in enumerate(store_messages):
|
||||
role = str(getattr(m, "role", "") or "")
|
||||
event_type = str(getattr(m, "event_type", "") or "").strip().lower()
|
||||
if event_type == "reasoning":
|
||||
|
|
@ -408,6 +468,19 @@ def build_llm_messages(
|
|||
tool_calls = None
|
||||
|
||||
if tool_calls and isinstance(tool_calls, list):
|
||||
# Guard: only include tool_calls if tool results exist later in this trimmed window.
|
||||
want_ids = [str(tc.get("id") or "").strip() for tc in tool_calls if isinstance(tc, dict) and str(tc.get("id") or "").strip()]
|
||||
suffix = tool_ids_after[i] if (i >= 0 and i < len(tool_ids_after)) else set()
|
||||
if want_ids and any(tid not in suffix for tid in want_ids):
|
||||
tool_calls = None
|
||||
if tool_calls is None:
|
||||
out.append(
|
||||
_attach_reasoning_content(
|
||||
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
||||
m,
|
||||
)
|
||||
)
|
||||
continue
|
||||
api_tool_calls = []
|
||||
gemini_fc = gemini_openai_compat_client(model)
|
||||
for idx, tc in enumerate(tool_calls):
|
||||
|
|
@ -439,16 +512,29 @@ def build_llm_messages(
|
|||
api_tool_calls.append(entry)
|
||||
if api_tool_calls:
|
||||
out.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": _strip_reasoning_blocks(getattr(m, "content", "") or ""),
|
||||
"tool_calls": api_tool_calls,
|
||||
}
|
||||
_attach_reasoning_content(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": _strip_reasoning_blocks(getattr(m, "content", "") or ""),
|
||||
"tool_calls": api_tool_calls,
|
||||
},
|
||||
m,
|
||||
)
|
||||
)
|
||||
else:
|
||||
out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")})
|
||||
out.append(
|
||||
_attach_reasoning_content(
|
||||
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
||||
m,
|
||||
)
|
||||
)
|
||||
else:
|
||||
out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")})
|
||||
out.append(
|
||||
_attach_reasoning_content(
|
||||
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
||||
m,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
|
|
|
|||
|
|
@ -807,6 +807,7 @@ def _persist_assistant_step(
|
|||
assistant_text: str,
|
||||
reasoning_text: str,
|
||||
llm_tool_calls: list[Any],
|
||||
thinking_mode_enabled: bool = False,
|
||||
) -> _LoopStepResult:
|
||||
stored_tool_calls = []
|
||||
for tc in llm_tool_calls:
|
||||
|
|
@ -819,19 +820,18 @@ def _persist_assistant_step(
|
|||
}
|
||||
)
|
||||
|
||||
reasoning_chunks, assistant_body = _split_reasoning_and_body(
|
||||
assistant_text,
|
||||
explicit_reasoning=reasoning_text,
|
||||
)
|
||||
for idx, chunk in enumerate(reasoning_chunks):
|
||||
store.add_message(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
content=chunk,
|
||||
turn_uuid=turn_uuid,
|
||||
event_type="reasoning",
|
||||
event_payload={"chunk_index": int(idx), "chunk_count": len(reasoning_chunks)},
|
||||
)
|
||||
reasoning_chunks, assistant_body = _split_reasoning_and_body(assistant_text, explicit_reasoning=reasoning_text)
|
||||
reasoning_full = "\n".join([str(x or "").strip() for x in reasoning_chunks if str(x or "").strip()]).strip()
|
||||
if not thinking_mode_enabled:
|
||||
for idx, chunk in enumerate(reasoning_chunks):
|
||||
store.add_message(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
content=chunk,
|
||||
turn_uuid=turn_uuid,
|
||||
event_type="reasoning",
|
||||
event_payload={"chunk_index": int(idx), "chunk_count": len(reasoning_chunks)},
|
||||
)
|
||||
assistant_row = store.add_message(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
|
|
@ -839,6 +839,7 @@ def _persist_assistant_step(
|
|||
tool_calls=stored_tool_calls or None,
|
||||
turn_uuid=turn_uuid,
|
||||
event_type="tool_call" if stored_tool_calls else "assistant_text",
|
||||
event_payload=({"reasoning_content": reasoning_full} if (thinking_mode_enabled and reasoning_full) else None),
|
||||
)
|
||||
return _LoopStepResult(
|
||||
assistant_text=assistant_body,
|
||||
|
|
@ -1002,6 +1003,7 @@ def run_oclaw_direct_loop(
|
|||
assistant_text=assistant_text,
|
||||
reasoning_text=reasoning_text,
|
||||
llm_tool_calls=llm_tool_calls,
|
||||
thinking_mode_enabled=bool(getattr(model, "thinking_mode_enabled", False)),
|
||||
)
|
||||
final_text = step.assistant_text
|
||||
if not step.llm_tool_calls:
|
||||
|
|
@ -1082,6 +1084,7 @@ def run_oclaw_direct_loop(
|
|||
assistant_text=str(getattr(resp, "content", "") or ""),
|
||||
reasoning_text=str(getattr(resp, "reasoning_content", "") or ""),
|
||||
llm_tool_calls=[],
|
||||
thinking_mode_enabled=bool(getattr(model, "thinking_mode_enabled", False)),
|
||||
)
|
||||
final_text = step.assistant_text
|
||||
|
||||
|
|
|
|||
|
|
@ -196,14 +196,57 @@ class McpProcessRuntime:
|
|||
req = {"jsonrpc": "2.0", "id": rid, "method": str(method), "params": params or {}}
|
||||
p.stdin.write(json.dumps(req, ensure_ascii=False) + "\n")
|
||||
p.stdin.flush()
|
||||
skipped: list[str] = []
|
||||
max_skip = 60
|
||||
while True:
|
||||
line = p.stdout.readline()
|
||||
if not line:
|
||||
return {"ok": False, "error_code": "mcp_runtime_empty_response", "error": "empty_response"}
|
||||
# Process may have exited early (common when runtime deps are missing).
|
||||
rc = None
|
||||
try:
|
||||
rc = p.poll()
|
||||
except Exception:
|
||||
rc = None
|
||||
err_tail = ""
|
||||
if rc is not None and p.stderr is not None:
|
||||
try:
|
||||
err_tail = (p.stderr.read() or "")[-2000:]
|
||||
except Exception:
|
||||
err_tail = ""
|
||||
out: dict[str, Any] = {"ok": False, "error_code": "mcp_runtime_empty_response", "error": "empty_response"}
|
||||
if rc is not None:
|
||||
out["exit_code"] = int(rc)
|
||||
if err_tail.strip():
|
||||
out["stderr_tail"] = err_tail.strip()
|
||||
return out
|
||||
s = str(line).strip()
|
||||
if not s:
|
||||
# Some servers emit blank lines; ignore.
|
||||
continue
|
||||
# Some MCP servers print logs/banner to stdout. Skip non-JSON lines until a JSON-RPC response arrives.
|
||||
if not (s.startswith("{") or s.startswith("[")):
|
||||
skipped.append(s[:200])
|
||||
if len(skipped) > max_skip:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "mcp_runtime_protocol_mismatch",
|
||||
"error": "non_jsonrpc_response",
|
||||
"skipped": skipped[-12:],
|
||||
}
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
obj = json.loads(s)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error_code": "mcp_runtime_bad_json", "error": str(exc)}
|
||||
# If a server mixes JSON with log fragments, keep skipping until we see a clean JSON object.
|
||||
skipped.append(s[:200])
|
||||
if len(skipped) > max_skip:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "mcp_runtime_bad_json",
|
||||
"error": str(exc),
|
||||
"skipped": skipped[-12:],
|
||||
}
|
||||
continue
|
||||
if not isinstance(obj, dict):
|
||||
return {"ok": False, "error_code": "mcp_runtime_invalid_payload", "error": "response_not_object"}
|
||||
if "jsonrpc" not in obj and "id" not in obj:
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@
|
|||
|
||||
## 主要事项:
|
||||
- 工具失败时先报告 `error_code` 与原因,再给下一步。
|
||||
- Windows(PowerShell/CMD)如遇部分命令“空输出/乱码”,优先尝试 `chcp 65001 > nul && <command>`。
|
||||
- 禁止伪造工具结果。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue