Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 72 additions & 21 deletions agent/coding_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,25 +635,32 @@ def _read_small(path: Path) -> str:
return ""


def _project_facts(root: Path) -> list[str]:
"""Detected project facts for the workspace snapshot.
@dataclass(frozen=True)
class ProjectFacts:
"""Structured project facts — the model's verify loop, detected once.

The point is to hand the model its *verify loop* up front — which manifest,
which package manager, and the exact test/lint/build commands — instead of
making it rediscover them every session. Cheap: stat calls plus reads of a
couple of small files; built once at prompt-build time (cache-safe).
The same data that feeds the workspace snapshot, exposed structurally so
non-prompt consumers (e.g. the desktop verify UI) read it instead of
re-detecting and drifting from the prompt.
"""
facts: list[str] = []

manifests: list[str]
package_managers: list[str]
verify_commands: list[str]
context_files: list[str]


def detect_project_facts(root: Path) -> ProjectFacts:
"""Detect manifests, package manager(s), verify commands, and context files.

Cheap: stat calls plus reads of a couple of small files. The single source
of truth for both the prompt snapshot (:func:`_project_facts`) and the
gateway's ``project.facts`` — so the UI never re-sniffs verify commands.
"""
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
package_managers = [
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
]
if manifests:
line = f"- Project: {', '.join(manifests[:6])}"
if package_managers:
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
facts.append(line)
package_managers = list(
dict.fromkeys(pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file())
)

verify: list[str] = []
if (root / "scripts" / "run_tests.sh").is_file():
Expand All @@ -673,17 +680,61 @@ def _project_facts(root: Path) -> list[str]:
f"make {name}" for name in _VERIFY_TARGETS
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
)
if verify:
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
facts.append(f"- Verify: {'; '.join(deduped)}")

context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
if context_files:
facts.append(f"- Context files: {', '.join(context_files)}")
return ProjectFacts(
manifests=manifests,
package_managers=package_managers,
verify_commands=list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS],
context_files=[c for c in _CONTEXT_FILES if (root / c).is_file()],
)


def _project_facts(root: Path) -> list[str]:
"""Render :func:`detect_project_facts` as workspace-snapshot lines.

Hands the model its *verify loop* up front — which manifest, which package
manager, and the exact test/lint/build commands — instead of making it
rediscover them every session. Built once at prompt-build time; the string
output must stay byte-stable to preserve the prompt cache.
"""
f = detect_project_facts(root)
facts: list[str] = []

if f.manifests:
line = f"- Project: {', '.join(f.manifests[:6])}"
if f.package_managers:
line += f" ({'/'.join(f.package_managers)})"
facts.append(line)
if f.verify_commands:
facts.append(f"- Verify: {'; '.join(f.verify_commands)}")
if f.context_files:
facts.append(f"- Context files: {', '.join(f.context_files)}")

return facts


def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, Any]]:
"""Structured project facts for ``cwd`` — ``None`` outside a workspace.

Same detection the system-prompt snapshot uses (git root, else marker root),
exposed for non-prompt consumers (the desktop verify UI) so they never
re-derive "are we coding?" or duplicate the verify-command sniffing.
"""
resolved = _resolve_cwd(cwd)
root = _git_root(resolved) or _marker_root(resolved)
if root is None:
return None

f = detect_project_facts(root)
return {
"root": str(root),
"manifests": f.manifests,
"packageManagers": f.package_managers,
"verifyCommands": f.verify_commands,
"contextFiles": f.context_files,
}


def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
"""Workspace snapshot for the system prompt (empty outside a workspace).

Expand Down
29 changes: 29 additions & 0 deletions tests/agent/test_coding_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,35 @@ def test_malformed_package_json_is_ignored(self, tmp_path):
assert "Project: package.json" in block
assert "Verify:" not in block

def test_detect_project_facts_structured(self, tmp_path):
(tmp_path / "package.json").write_text(
json.dumps({"scripts": {"test": "vitest", "dev": "vite"}})
)
(tmp_path / "pnpm-lock.yaml").write_text("")
facts = cc.detect_project_facts(tmp_path)
assert facts.manifests == ["package.json"]
assert facts.package_managers == ["pnpm"]
assert facts.verify_commands == ["pnpm run test"] # dev excluded
assert facts.context_files == []

def test_project_facts_for_matches_prompt_block(self, tmp_path):
# Invariant: the structured facts the UI consumes must not drift from the
# commands the prompt snapshot renders — one detector feeds both.
_git_init(tmp_path)
(tmp_path / "package.json").write_text(
json.dumps({"scripts": {"test": "vitest", "lint": "eslint ."}})
)
(tmp_path / "pnpm-lock.yaml").write_text("")
facts = cc.project_facts_for(tmp_path)
assert facts is not None
verify_line = cc.build_coding_workspace_block(tmp_path).split("Verify:")[1].splitlines()[0]
assert facts["verifyCommands"]
for cmd in facts["verifyCommands"]:
assert cmd in verify_line

def test_project_facts_for_none_outside_workspace(self, tmp_path):
assert cc.project_facts_for(tmp_path) is None


# ── $HOME dotfiles guard ────────────────────────────────────────────────────

Expand Down
18 changes: 18 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4533,6 +4533,24 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"session_id": None})


@method("project.facts")
def _(rid, params: dict) -> dict:
"""Structured project facts for a cwd — manifests, package manager, the
exact verify commands, and context files.

The same detection the coding-context posture (#43316) bakes into the system
prompt, exposed so UIs (the desktop verify surface) consume it instead of
re-sniffing. ``{"facts": null}`` means the cwd isn't a code workspace.
"""
try:
from agent.coding_context import project_facts_for

return _ok(rid, {"facts": project_facts_for(params.get("cwd"))})
except Exception:
logger.exception("project.facts failed")
return _ok(rid, {"facts": None})


@method("session.resume")
def _(rid, params: dict) -> dict:
target = params.get("session_id", "")
Expand Down
Loading