oclaw/runtime/project_context_prompt.py
oliver 420abac9f1 refactor: root-package imports (svc/runtime/interfaces) and fix PYTHONPATH
- Rename platform/ to svc/ to avoid shadowing stdlib platform.
- Replace from oclaw.* with from svc/runtime/interfaces; update -m CLI paths.
- tests/conftest: prepend repo root to sys.path (no parent-folder package name).
- CI: paths and offline_eval script under repo root.
- Ops scripts: PYTHONPATH must be repo root for python -m runtime.* (fixes gateway/WhatsApp sidecar startup).
- Fix default oclaw.json path in tabular/file attachment limits; stabilize attachment test config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 14:51:17 +08:00

183 lines
6.1 KiB
Python

from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from runtime.agents.agent_scope import resolve_agent_id_by_workspace_path
from runtime.hooks_runtime import get_active_hooks_config, trigger_hook_event
from svc.config.paths import PROJECT_ROOT
from runtime.prompt_templates import render_runtime_prompt
_PROJECT_CONTEXT_FILES: tuple[str, ...] = (
"TOOLS.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
)
def _project_context_roots() -> tuple[Path, ...]:
roots: list[Path] = []
raw_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip()
if raw_ws:
roots.append(Path(raw_ws).expanduser())
roots.append(Path(PROJECT_ROOT) / "runtime" / "workspaces" / "main")
roots.append(Path(PROJECT_ROOT))
out: list[Path] = []
seen: set[str] = set()
for p in roots:
key = str(p.resolve()) if p.exists() else str(p)
if key in seen:
continue
seen.add(key)
out.append(p)
return tuple(out)
def _max_file_chars(store: Any) -> int:
try:
raw = str(store.get_setting("AIA_PROJECT_CONTEXT_MAX_FILE_CHARS") or "").strip()
if raw.isdigit():
return max(200, min(int(raw), 200_000))
except Exception:
pass
return 12_000
def _max_total_chars(store: Any) -> int:
try:
raw = str(store.get_setting("AIA_PROJECT_CONTEXT_MAX_TOTAL_CHARS") or "").strip()
if raw.isdigit():
return max(1_000, min(int(raw), 1_000_000))
except Exception:
pass
return 60_000
def build_project_context_block(*, store: Any, workspace_dir: str | None = None) -> str:
per_file_cap = _max_file_chars(store)
total_cap = _max_total_chars(store)
total = 0
lines: list[str] = []
cfg = get_active_hooks_config()
roots: list[Path] = []
# When workspace_dir is provided, isolate project context to that workspace
# plus the shared PROJECT_ROOT fallback (avoid cross-agent pollution).
if isinstance(workspace_dir, str) and workspace_dir.strip():
roots.append(Path(workspace_dir).expanduser())
roots.append(Path(PROJECT_ROOT))
else:
roots = list(_project_context_roots())
# Include configured agent workspaces as additional roots (multi-agent support).
try:
agents = cfg.get("agents") if isinstance(cfg, dict) else None
entries = agents.get("list") if isinstance(agents, dict) else None
if isinstance(entries, list):
for e in entries:
if not isinstance(e, dict):
continue
ws = str(e.get("workspace") or "").strip()
if ws:
roots.append(Path(ws).expanduser())
except Exception:
pass
# De-dupe roots while preserving order.
deduped: list[Path] = []
seen: set[str] = set()
for p in roots:
key = str(p.resolve()) if p.exists() else str(p)
if key in seen:
continue
seen.add(key)
deduped.append(p)
roots = deduped
bootstrap_files: list[dict[str, str]] = []
root_bootstrap_files: list[tuple[str, list[dict[str, str]]]] = []
for root in roots:
one_root_files: list[dict[str, str]] = []
for name in _PROJECT_CONTEXT_FILES:
cand = (root / name).resolve()
if cand.exists() and cand.is_file():
one_root_files.append({"path": str(cand), "name": name})
root_bootstrap_files.append((str(root), one_root_files))
# Trigger bootstrap hooks for each discovered workspace root so multi-agent
# workspaces can contribute extra bootstrap files independently.
cfg = cfg
for idx, (workspace_dir, one_root_files) in enumerate(root_bootstrap_files):
agent_id = None
try:
agent_id = resolve_agent_id_by_workspace_path(cfg, workspace_dir)
except Exception:
agent_id = None
agent_part = str(agent_id or "unknown").strip() or "unknown"
# keep session keys log-friendly; avoid long absolute paths.
ws_part = Path(workspace_dir).name or "workspace"
trigger_hook_event(
event_type="agent",
action="bootstrap",
session_key=f"system:agent:bootstrap:{agent_part}:{ws_part}:{idx}",
context={
"workspaceDir": workspace_dir,
"bootstrapFiles": one_root_files,
"cfg": cfg,
"agentId": agent_id,
},
)
bootstrap_files.extend(one_root_files)
seen_paths: set[str] = set()
candidate_paths: list[tuple[str, str]] = []
for item in bootstrap_files:
if not isinstance(item, dict):
continue
p = str(item.get("path") or "").strip()
n = str(item.get("name") or "").strip() or Path(p).name
if not p or p in seen_paths:
continue
seen_paths.add(p)
candidate_paths.append((p, n))
for name in _PROJECT_CONTEXT_FILES:
p: Path | None = None
for root in roots:
cand = (root / name).resolve()
if cand.exists() and cand.is_file():
p = cand
break
if p is None:
continue
if str(p) not in seen_paths:
seen_paths.add(str(p))
candidate_paths.append((str(p), name))
for raw_path, name in candidate_paths:
p = Path(raw_path)
if not p.exists() or not p.is_file():
continue
try:
raw = p.read_text(encoding="utf-8")
except Exception:
continue
txt = str(raw or "").strip()
if not txt:
continue
clipped = txt[:per_file_cap]
remain = max(0, total_cap - total)
if remain <= 0:
break
chunk = clipped[:remain]
if not chunk:
continue
total += len(chunk)
lines.append(f"[{name}]")
lines.append(chunk)
lines.append("")
if not lines:
return ""
return render_runtime_prompt(
"runtime/project_context_block.md",
variables={"project_context": "\n".join(lines).strip()},
strict=True,
)
__all__ = ["build_project_context_block"]