mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 05:50:44 +08:00
完善 MCP/会话上下文治理并补齐搜索与诊断能力。
补充历史压缩与会话诊断链路,强化 tool pairing 与空响应兜底观测;完善 Admin MCP 导入/绑定与相关前端展示;新增 web_search_fast/web_fetch_clean 及多项测试与文档更新,并同步技能安装与角色绑定策略改进。 Made-with: Cursor
This commit is contained in:
parent
df91434936
commit
ae44cbcad5
43 changed files with 3549 additions and 57 deletions
|
|
@ -23,6 +23,10 @@ from oclaw.runtime.relay_pointer import parse_pointer_uri
|
|||
logger = logging.getLogger(__name__)
|
||||
_THINK_BLOCK_RE = re.compile(r"<think>\s*(.*?)\s*</think>\s*", flags=re.IGNORECASE | re.DOTALL)
|
||||
_TOOL_CONTEXT_RESULT_MAX_CHARS = 50
|
||||
_LAST_BUILD_LLM_MESSAGES_STATS: dict[str, int] = {
|
||||
"dropped_unpaired_tool_rows": 0,
|
||||
"dropped_no_id_tool_rows": 0,
|
||||
}
|
||||
|
||||
|
||||
def _replay_recent_tool_rounds() -> int:
|
||||
|
|
@ -210,6 +214,8 @@ def build_llm_messages(
|
|||
older user attachments are replayed as text metadata only.
|
||||
"""
|
||||
out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}]
|
||||
dropped_unpaired_tool_rows = 0
|
||||
dropped_no_id_tool_rows = 0
|
||||
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]]] = {}
|
||||
|
|
@ -250,6 +256,7 @@ def build_llm_messages(
|
|||
seen_tool_ids.add(tcid)
|
||||
tool_ids_after.append(set(seen_tool_ids))
|
||||
tool_ids_after.reverse()
|
||||
pending_tool_ids_for_next_tool_rows: set[str] = set()
|
||||
|
||||
last_user_msg_idx = -1
|
||||
for _ui, _um in enumerate(store_messages or []):
|
||||
|
|
@ -550,6 +557,11 @@ def build_llm_messages(
|
|||
entry["extra_content"] = {"google": {"thought_signature": raw_sig}}
|
||||
api_tool_calls.append(entry)
|
||||
if api_tool_calls:
|
||||
pending_tool_ids_for_next_tool_rows = {
|
||||
str(tc.get("id") or "").strip()
|
||||
for tc in api_tool_calls
|
||||
if str(tc.get("id") or "").strip()
|
||||
}
|
||||
out.append(
|
||||
_attach_reasoning_content(
|
||||
{
|
||||
|
|
@ -561,6 +573,7 @@ def build_llm_messages(
|
|||
)
|
||||
)
|
||||
else:
|
||||
pending_tool_ids_for_next_tool_rows = set()
|
||||
out.append(
|
||||
_attach_reasoning_content(
|
||||
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
||||
|
|
@ -568,6 +581,7 @@ def build_llm_messages(
|
|||
)
|
||||
)
|
||||
else:
|
||||
pending_tool_ids_for_next_tool_rows = set()
|
||||
out.append(
|
||||
_attach_reasoning_content(
|
||||
{"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")},
|
||||
|
|
@ -593,26 +607,15 @@ def build_llm_messages(
|
|||
tool_call_id = ""
|
||||
if tool_call_id:
|
||||
# Guard against dangling tool_call_id (assistant tool_calls missing from this trimmed context window).
|
||||
if str(tool_call_id) not in valid_tool_call_ids:
|
||||
# Preserve tool evidence, but downgrade to plain assistant text when pairing is broken.
|
||||
# Some OpenAI-compatible gateways reject a role=tool message if tool_call_id cannot be paired
|
||||
# to an assistant.tool_calls.id within the same request context.
|
||||
r0 = getattr(m, "content", "") or ""
|
||||
cap0 = tool_llm_message_max_chars()
|
||||
pretty = _summarize_unpaired_tool_content(r0, cap=cap0)
|
||||
if tool_context_truncate_enabled:
|
||||
pretty = _truncate_tool_context(pretty, lang=lang)
|
||||
out.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": render_prompt(
|
||||
"tools/tool_result_unpaired.md",
|
||||
variables={"tag": "tool_use_result:unpaired", "payload": pretty},
|
||||
strict=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
# Also require strict immediate-turn pairing: a tool row must follow the assistant
|
||||
# tool_calls message that introduced this id (no unrelated message in-between).
|
||||
# Some OpenAI-compatible gateways enforce this strictly.
|
||||
if str(tool_call_id) not in valid_tool_call_ids or str(tool_call_id) not in pending_tool_ids_for_next_tool_rows:
|
||||
# Strict pairing mode: drop unpaired tool rows entirely.
|
||||
# This avoids provider-side 400 errors caused by orphan tool_result blocks.
|
||||
dropped_unpaired_tool_rows += 1
|
||||
continue
|
||||
pending_tool_ids_for_next_tool_rows.discard(str(tool_call_id))
|
||||
raw_tc_content = getattr(m, "content", "") or ""
|
||||
_tun = str(getattr(m, "turn_uuid", "") or "").strip()
|
||||
_aus = str(active_turn_uuid or "").strip()
|
||||
|
|
@ -661,23 +664,21 @@ def build_llm_messages(
|
|||
tool_row["name"] = str(meta2["name"])
|
||||
out.append(tool_row)
|
||||
else:
|
||||
r = getattr(m, "content", "") or ""
|
||||
cap2 = tool_llm_message_max_chars()
|
||||
pretty2 = _summarize_unpaired_tool_content(r, cap=cap2)
|
||||
# Unpaired tool rows are already summarized; avoid extra 50-char clipping.
|
||||
out.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": render_prompt(
|
||||
"tools/tool_result_unpaired.md",
|
||||
variables={"tag": "tool_use_result:no_id", "payload": pretty2},
|
||||
strict=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
pending_tool_ids_for_next_tool_rows = set()
|
||||
# Strict pairing mode: drop no-id tool rows entirely.
|
||||
dropped_no_id_tool_rows += 1
|
||||
continue
|
||||
|
||||
global _LAST_BUILD_LLM_MESSAGES_STATS
|
||||
_LAST_BUILD_LLM_MESSAGES_STATS = {
|
||||
"dropped_unpaired_tool_rows": int(dropped_unpaired_tool_rows),
|
||||
"dropped_no_id_tool_rows": int(dropped_no_id_tool_rows),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["build_llm_messages"]
|
||||
def get_last_build_llm_messages_stats() -> dict[str, int]:
|
||||
return dict(_LAST_BUILD_LLM_MESSAGES_STATS)
|
||||
|
||||
|
||||
__all__ = ["build_llm_messages", "get_last_build_llm_messages_stats"]
|
||||
|
|
|
|||
244
runtime/chat/history_tool_result_compact.py
Normal file
244
runtime/chat/history_tool_result_compact.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.chat.media_redact import redact_embedded_image_blobs
|
||||
from oclaw.runtime.direct_loop import (
|
||||
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS,
|
||||
_image_tool_result_replay_cap_chars,
|
||||
_video_tool_result_replay_cap_chars,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryCompactionResult:
|
||||
ok: bool
|
||||
session_id: str
|
||||
scanned_tool_messages: int = 0
|
||||
compacted_tool_messages: int = 0
|
||||
rewritten_all_tool_messages: int = 0
|
||||
skipped_already_guarded: int = 0
|
||||
max_original_chars_seen: int = 0
|
||||
cap_chars: int = 0
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def _json_dumps_safe(obj: Any) -> str:
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return json.dumps({"ok": False, "error": "not_json_serializable"}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _guard_tool_result_text_for_history(
|
||||
*,
|
||||
store: Any,
|
||||
raw: str,
|
||||
cap_chars: int,
|
||||
image_cap_chars: int,
|
||||
video_cap_chars: int,
|
||||
) -> tuple[str, bool]:
|
||||
"""Return (new_raw, changed) following the same strategy as context replay guard."""
|
||||
text = str(raw or "")
|
||||
if not text:
|
||||
return text, False
|
||||
# Redact embedded blobs first (same as context guard).
|
||||
try:
|
||||
p0 = json.loads(text)
|
||||
p1 = redact_embedded_image_blobs(p0)
|
||||
text = _json_dumps_safe(p1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If already guarded, do not rewrite again.
|
||||
try:
|
||||
obj0 = json.loads(text)
|
||||
if isinstance(obj0, dict) and (obj0.get("_tool_result_guarded") or obj0.get("_image_tool_result_guarded") or obj0.get("_video_tool_result_guarded")):
|
||||
return text, False
|
||||
except Exception:
|
||||
obj0 = None
|
||||
|
||||
ok = None
|
||||
error_code = ""
|
||||
error = ""
|
||||
obj: dict[str, Any] | None = None
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
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()
|
||||
t = str(obj.get("text") or "")
|
||||
has_attachment_id = bool(str(obj.get("attachment_id") or "").strip())
|
||||
if task in {"describe", "ocr"} and has_attachment_id and len(t) > int(image_cap_chars):
|
||||
preview = t[: int(image_cap_chars)] + "\n...<image_tool_result_truncated_for_context_replay>"
|
||||
guarded_obj = dict(obj)
|
||||
guarded_obj["text"] = preview
|
||||
guarded_obj["_image_tool_result_guarded"] = True
|
||||
guarded_obj["image_result_original_chars"] = len(t)
|
||||
guarded_obj["image_result_replay_cap_chars"] = int(image_cap_chars)
|
||||
guarded_obj["image_result_hint"] = (
|
||||
"Image analysis result was truncated for context replay. "
|
||||
"Refine query_image_attachment(question=...) for narrower evidence. / "
|
||||
"图片分析结果在上下文回放中已截断,请缩小 query_image_attachment 的问题范围。"
|
||||
)
|
||||
return _json_dumps_safe(guarded_obj), True
|
||||
if task == "transcript" and has_attachment_id and len(t) > int(video_cap_chars):
|
||||
preview = t[: int(video_cap_chars)] + "\n...<video_tool_result_truncated_for_context_replay>"
|
||||
guarded_obj = dict(obj)
|
||||
guarded_obj["text"] = preview
|
||||
guarded_obj["_video_tool_result_guarded"] = True
|
||||
guarded_obj["video_result_original_chars"] = len(t)
|
||||
guarded_obj["video_result_replay_cap_chars"] = int(video_cap_chars)
|
||||
return _json_dumps_safe(guarded_obj), True
|
||||
|
||||
if len(text) <= int(cap_chars):
|
||||
return text, False
|
||||
|
||||
preview = text[: max(1, min(4000, int(cap_chars) - 400))] + "\n...<tool_result_guard_truncated>"
|
||||
guarded_obj = {
|
||||
"ok": bool(ok) if ok is not None else None,
|
||||
"error_code": error_code,
|
||||
"error": error,
|
||||
"_tool_result_guarded": True,
|
||||
"original_chars": len(text),
|
||||
"guard_cap_chars": int(cap_chars),
|
||||
"preview": preview,
|
||||
"hint": (
|
||||
"Tool output was too large for safe context replay; it was truncated for history storage. "
|
||||
"Use narrower queries (e.g., smaller glob/max_results) or adjust AIA_TOOL_LLM_MESSAGE_MAX_CHARS. / "
|
||||
"工具输出过大,已压缩写回历史;请缩小范围或配置 AIA_TOOL_LLM_MESSAGE_MAX_CHARS。"
|
||||
),
|
||||
}
|
||||
return _json_dumps_safe(guarded_obj), True
|
||||
|
||||
|
||||
def compact_tool_results_in_session_history(
|
||||
*,
|
||||
store: Any,
|
||||
session_id: str,
|
||||
cap_chars: int | None = None,
|
||||
limit_messages: int = 5000,
|
||||
rewrite_all: bool = True,
|
||||
) -> HistoryCompactionResult:
|
||||
"""Rewrite overlarge `role=tool` chat_message.content in DB using the replay-guard strategy."""
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return HistoryCompactionResult(ok=False, session_id="", detail="session_id_required")
|
||||
cap = max(4096, min(int(cap_chars or _OCLAW_TOOL_RESULT_HARD_CAP_CHARS), 500_000))
|
||||
image_cap = int(_image_tool_result_replay_cap_chars(store))
|
||||
video_cap = int(_video_tool_result_replay_cap_chars(store))
|
||||
scanned = 0
|
||||
compacted = 0
|
||||
rewritten_all = 0
|
||||
skipped = 0
|
||||
max_seen = 0
|
||||
|
||||
# We only need to scan tool messages; fetching ids+content is enough.
|
||||
with store._connect() as conn: # noqa: SLF001
|
||||
cur = conn.execute(
|
||||
"select id, content from chat_message where session_id=? and role='tool' "
|
||||
"order by id asc limit ?",
|
||||
(sid, max(1, min(int(limit_messages or 5000), 200_000))),
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
for mid, raw in rows:
|
||||
scanned += 1
|
||||
txt = str(raw or "")
|
||||
max_seen = max(max_seen, len(txt))
|
||||
new_txt, changed = _guard_tool_result_text_for_history(
|
||||
store=store,
|
||||
raw=txt,
|
||||
cap_chars=cap,
|
||||
image_cap_chars=image_cap,
|
||||
video_cap_chars=video_cap,
|
||||
)
|
||||
# Defensive fallback: if content is still over cap but guard didn't report change,
|
||||
# force a minimal guard so polluted history can always be compacted.
|
||||
if (not changed) and len(txt) > int(cap):
|
||||
preview = txt[: max(1, min(4000, int(cap) - 400))] + "\n...<tool_result_guard_truncated>"
|
||||
new_txt = _json_dumps_safe(
|
||||
{
|
||||
"ok": None,
|
||||
"error_code": "",
|
||||
"error": "",
|
||||
"_tool_result_guarded": True,
|
||||
"original_chars": len(txt),
|
||||
"guard_cap_chars": int(cap),
|
||||
"preview": preview,
|
||||
"hint": (
|
||||
"Tool output was too large for safe context replay; it was truncated for history storage. / "
|
||||
"工具输出过大,已压缩写回历史。"
|
||||
),
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
# Full rewrite mode: compact every tool_result row into guarded envelope,
|
||||
# even when current content is under cap. This keeps history bounded and
|
||||
# prevents heterogeneous huge payload persistence.
|
||||
if (not changed) and bool(rewrite_all):
|
||||
preview_cap = max(200, min(1200, int(cap) - 200))
|
||||
preview = txt[:preview_cap]
|
||||
if len(txt) > preview_cap:
|
||||
preview += "\n...<tool_result_guard_truncated>"
|
||||
ok_val = None
|
||||
try:
|
||||
_obj = json.loads(txt)
|
||||
if isinstance(_obj, dict):
|
||||
_ok = _obj.get("ok")
|
||||
ok_val = bool(_ok) if _ok is not None else None
|
||||
except Exception:
|
||||
ok_val = None
|
||||
new_txt = _json_dumps_safe(
|
||||
{
|
||||
"ok": ok_val,
|
||||
"error_code": "",
|
||||
"error": "",
|
||||
"_tool_result_guarded": True,
|
||||
"_history_full_rewrite": True,
|
||||
"original_chars": len(txt),
|
||||
"guard_cap_chars": int(cap),
|
||||
"preview": preview,
|
||||
"hint": (
|
||||
"Tool result history was compacted by operator action. / "
|
||||
"该工具结果历史已按运维操作统一压缩。"
|
||||
),
|
||||
}
|
||||
)
|
||||
changed = (new_txt != txt)
|
||||
if changed:
|
||||
rewritten_all += 1
|
||||
if not changed:
|
||||
# could be already guarded, or under cap
|
||||
if txt and ("_tool_result_guarded" in txt or "_image_tool_result_guarded" in txt or "_video_tool_result_guarded" in txt):
|
||||
skipped += 1
|
||||
continue
|
||||
conn.execute("update chat_message set content=? where id=? and session_id=?", (new_txt, int(mid), sid))
|
||||
compacted += 1
|
||||
|
||||
return HistoryCompactionResult(
|
||||
ok=True,
|
||||
session_id=sid,
|
||||
scanned_tool_messages=int(scanned),
|
||||
compacted_tool_messages=int(compacted),
|
||||
rewritten_all_tool_messages=int(rewritten_all),
|
||||
skipped_already_guarded=int(skipped),
|
||||
max_original_chars_seen=int(max_seen),
|
||||
cap_chars=int(cap),
|
||||
detail="ok",
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HistoryCompactionResult",
|
||||
"compact_tool_results_in_session_history",
|
||||
]
|
||||
|
||||
105
runtime/chat/session_context_diagnostics.py
Normal file
105
runtime/chat/session_context_diagnostics.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContextStats:
|
||||
session_id: str
|
||||
total_messages: int
|
||||
sampled_messages: int
|
||||
last_n: int
|
||||
|
||||
last_n_total_chars: int
|
||||
last_n_tool_total_chars: int
|
||||
last_n_max_msg_chars: int
|
||||
last_n_max_tool_chars: int
|
||||
last_n_max_user_chars: int
|
||||
|
||||
empty_assistant_text_in_sampled: int
|
||||
empty_assistant_text_ids: tuple[int, ...]
|
||||
|
||||
max_content_chars_in_sampled: int
|
||||
max_tool_chars_in_sampled: int
|
||||
|
||||
|
||||
def compute_session_context_stats(
|
||||
*,
|
||||
store: Any,
|
||||
session_id: str,
|
||||
sample_n: int = 120,
|
||||
last_n: int = 80,
|
||||
) -> SessionContextStats:
|
||||
"""Compute lightweight DB-backed stats for diagnosing context overflow vs empty responses.
|
||||
|
||||
This intentionally only inspects DB text lengths (not token counts).
|
||||
"""
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
raise ValueError("session_id required")
|
||||
sample_n = max(1, min(int(sample_n or 120), 2000))
|
||||
last_n = max(1, min(int(last_n or 80), 2000))
|
||||
|
||||
total = 0
|
||||
sampled_rows: list[tuple[int, str, str, int]] = []
|
||||
last_rows: list[tuple[int, str, str, int]] = []
|
||||
try:
|
||||
# Prefer raw SQL to avoid store-level object hydration overhead.
|
||||
with store._connect() as conn: # noqa: SLF001
|
||||
cur = conn.execute("select count(1) from chat_message where session_id=?", (sid,))
|
||||
total = int((cur.fetchone() or [0])[0] or 0)
|
||||
cur = conn.execute(
|
||||
"select id, role, event_type, coalesce(length(content),0) as n "
|
||||
"from chat_message where session_id=? order by id desc limit ?",
|
||||
(sid, int(sample_n)),
|
||||
)
|
||||
sampled_rows = [(int(r[0]), str(r[1] or ""), str(r[2] or ""), int(r[3] or 0)) for r in (cur.fetchall() or [])]
|
||||
cur = conn.execute(
|
||||
"select id, role, event_type, coalesce(length(content),0) as n "
|
||||
"from chat_message where session_id=? order by id desc limit ?",
|
||||
(sid, int(last_n)),
|
||||
)
|
||||
last_rows = [(int(r[0]), str(r[1] or ""), str(r[2] or ""), int(r[3] or 0)) for r in (cur.fetchall() or [])]
|
||||
except Exception:
|
||||
# Fallback path via store API if direct SQL fails for any reason.
|
||||
msgs = list(store.get_messages(session_id=sid, limit=int(sample_n)))
|
||||
total = len(msgs)
|
||||
sampled_rows = [
|
||||
(int(getattr(m, "id", 0) or 0), str(getattr(m, "role", "") or ""), str(getattr(m, "event_type", "") or ""), len(str(getattr(m, "content", "") or "")))
|
||||
for m in msgs
|
||||
]
|
||||
last_rows = sampled_rows[: int(last_n)]
|
||||
|
||||
empty_ids = tuple(sorted([mid for mid, role, ev, n in sampled_rows if role == "assistant" and ev == "assistant_text" and int(n) == 0]))
|
||||
max_content = max([n for *_rest, n in sampled_rows] or [0])
|
||||
max_tool = max([n for _mid, role, _ev, n in sampled_rows if role == "tool"] or [0])
|
||||
|
||||
last_total = sum(int(n) for *_rest, n in last_rows)
|
||||
last_tool_total = sum(int(n) for _mid, role, _ev, n in last_rows if role == "tool")
|
||||
last_max = max([int(n) for *_rest, n in last_rows] or [0])
|
||||
last_tool_max = max([int(n) for _mid, role, _ev, n in last_rows if role == "tool"] or [0])
|
||||
last_user_max = max([int(n) for _mid, role, _ev, n in last_rows if role == "user"] or [0])
|
||||
|
||||
return SessionContextStats(
|
||||
session_id=sid,
|
||||
total_messages=int(total),
|
||||
sampled_messages=len(sampled_rows),
|
||||
last_n=int(last_n),
|
||||
last_n_total_chars=int(last_total),
|
||||
last_n_tool_total_chars=int(last_tool_total),
|
||||
last_n_max_msg_chars=int(last_max),
|
||||
last_n_max_tool_chars=int(last_tool_max),
|
||||
last_n_max_user_chars=int(last_user_max),
|
||||
empty_assistant_text_in_sampled=len(empty_ids),
|
||||
empty_assistant_text_ids=empty_ids,
|
||||
max_content_chars_in_sampled=int(max_content),
|
||||
max_tool_chars_in_sampled=int(max_tool),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SessionContextStats",
|
||||
"compute_session_context_stats",
|
||||
]
|
||||
|
||||
45
runtime/chat/tool_invocation_context.py
Normal file
45
runtime/chat/tool_invocation_context.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Iterator
|
||||
|
||||
_tool_lane_owner: ContextVar[str | None] = ContextVar("tool_lane_owner", default=None)
|
||||
_tool_lane_session: ContextVar[str | None] = ContextVar("tool_lane_session", default=None)
|
||||
_tool_workspace_lane_role: ContextVar[str | None] = ContextVar("tool_workspace_lane_role", default=None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tool_workspace_lane_scope(
|
||||
*,
|
||||
workspace_owner_session_id: str | None,
|
||||
session_id: str | None,
|
||||
workspace_lane_role: str | None = None,
|
||||
) -> Iterator[None]:
|
||||
o = str(workspace_owner_session_id or "").strip() or None
|
||||
s = str(session_id or "").strip() or None
|
||||
r = str(workspace_lane_role or "").strip().lower() or None
|
||||
t_o = _tool_lane_owner.set(o)
|
||||
t_s = _tool_lane_session.set(s)
|
||||
t_r = _tool_workspace_lane_role.set(r)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tool_lane_owner.reset(t_o)
|
||||
_tool_lane_session.reset(t_s)
|
||||
_tool_workspace_lane_role.reset(t_r)
|
||||
|
||||
|
||||
def current_tool_lane_sessions() -> tuple[str | None, str | None]:
|
||||
return _tool_lane_owner.get(), _tool_lane_session.get()
|
||||
|
||||
|
||||
def current_tool_workspace_lane_role() -> str | None:
|
||||
return _tool_workspace_lane_role.get()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"current_tool_lane_sessions",
|
||||
"current_tool_workspace_lane_role",
|
||||
"tool_workspace_lane_scope",
|
||||
]
|
||||
|
|
@ -26,6 +26,7 @@ from oclaw.runtime.tools.path_guard import (
|
|||
workspace_path_access_scope,
|
||||
workspace_write_namespace_scope,
|
||||
)
|
||||
from oclaw.runtime.chat.tool_invocation_context import tool_workspace_lane_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_tool_exec_log = logging.getLogger("oclaw.tool_exec")
|
||||
|
|
@ -474,6 +475,8 @@ class ToolExecutionContext:
|
|||
path_policy_user_id: str | None = None
|
||||
workspace_dir: str | None = None
|
||||
turn_uuid: str | None = None
|
||||
#: Binding role for private ``skill_auto_install`` paths (``_workspace/<role>/``, sibling of ``public/``).
|
||||
workspace_lane_role: str | None = None
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
|
|
@ -516,7 +519,11 @@ class ToolExecutor:
|
|||
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):
|
||||
), workspace_write_namespace_scope(ws_ns), tool_workspace_lane_scope(
|
||||
workspace_owner_session_id=ctx.workspace_owner_session_id,
|
||||
session_id=ctx.session_id,
|
||||
workspace_lane_role=ctx.workspace_lane_role,
|
||||
):
|
||||
return tool.handler(tc.arguments)
|
||||
|
||||
if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue