oc_chat
Loading...
Searching...
No Matches
prompt_layers.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/prompt_layers.py
4
5"""
6Prompt-layer classification inventory.
7
8Purpose
9-------
10Phase C5 records the authoritative classification of prompt and
11request layers that affect prefix-cache stability. The runtime already
12separates the immutable system prefix from variable context; this module
13turns that separation into a typed inventory that tests and docs can
14hold stable.
15
16Plan 157 adds the session-anchors block (sandbox root + cwd + git branch)
17and an in-process ``.git/HEAD`` parser that handles linked-worktree
18``.git``-FILE indirection — no subprocess, no git invocation.
19
20Responsibilities
21----------------
22- :class:`PromptLayer` describes one prompt/request layer, where it is
23 assembled, and whether it is immutable or variable.
24- :data:`PROMPT_LAYERS` is the canonical C5 inventory.
25- :func:`prompt_layer_inventory` returns the immutable inventory tuple.
26- :func:`layers_by_classification` filters the inventory for tests and
27 diagnostics.
28- :func:`session_anchors_block` renders the session self-location block
29 (plan 157) — sandbox root, process cwd, and git branch.
30- :func:`_git_branch` is the in-process ``.git/HEAD`` parser that
31 handles normal repos, linked worktrees, and detached HEAD.
32
33Diagnostics
34-----------
35Domain: OC_CHAT.MAIN
36Levels:
37 L2 - lifecycle events
38 L3 - semantic details
39 L4 - deep tracing
40
41Contracts
42---------
43- Layer names are stable public identifiers for documentation and tests.
44- Classifications are deliberately binary: ``immutable`` means the layer
45 belongs in the cache-stable prefix; ``variable`` means it must remain
46 outside that prefix or be treated as per-turn/per-session data.
47- The inventory is descriptive. It must not mutate session messages.
48- ``_git_branch`` never invokes subprocess or git; it reads ``.git/HEAD``
49 (and the linked-worktree indirection file) via in-process file I/O.
50
51Dependencies
52------------
53- oc_diagnostics (_dbg)
54- oc_chat.tools.filesystem (lazy; _sandbox_root_info for the sandbox-root and source-label pair)
55"""
56from __future__ import annotations
57
58from dataclasses import dataclass
59from pathlib import Path
60from typing import Literal
61
62from oc_diagnostics import _dbg
63
64
65PromptLayerClassification = Literal["immutable", "variable"]
66
67
68@dataclass(frozen=True)
70 """One classified prompt or request layer."""
71
72 name: str
73 classification: PromptLayerClassification
74 role_or_scope: str
75 owner: str
76 source: str
77 cache_policy: str
78 notes: str
79
80
81PROMPT_LAYERS: tuple[PromptLayer, ...] = (
83 name="base_system_prompt",
84 classification="immutable",
85 role_or_scope="messages[0].role == system",
86 owner="oc_chat.core.policy.CANONICAL_POLICY",
87 source="oc_chat.core.session.ChatSession.messages",
88 cache_policy="Must remain byte-stable unless the user changes /system.",
89 notes="The first message anchors the cacheable prompt prefix.",
90 ),
92 name="compact_tool_map",
93 classification="immutable",
94 role_or_scope="messages[0].content suffix",
95 owner="oc_chat.core.tool_prompt.compact_tool_map",
96 source="one-line-per-tool map rendered from the mode-filtered registry",
97 cache_policy="Behind the compact-tool-map sentinel; changes only when the registered tools change.",
98 notes="Full JSON Schema is still sent through the API tools parameter (plan 135 §3.3).",
99 ),
101 name="variable_environment_context",
102 classification="variable",
103 role_or_scope="messages[1].content (## Environment sublayer)",
104 owner="oc_chat.core.startup._inject_variable_context",
105 source="date/timezone/cwd/branch/git/model/mode per the active mode env_fields",
106 cache_policy="Outside messages[0]; refreshed on session load and model switch.",
107 notes="Volatile facts like date + branch live here, never in messages[0] (plan 135 §3.5).",
108 ),
110 name="variable_context_user_message",
111 classification="variable",
112 role_or_scope="messages[1].role == user",
113 owner="oc_chat.core.startup._inject_variable_context",
114 source="project tree plus AGENT_CONTEXT.md",
115 cache_policy="Always outside messages[0]; refreshed on session load.",
116 notes="Marked by VARIABLE_CONTEXT_SENTINEL.",
117 ),
119 name="session_anchors",
120 classification="variable",
121 role_or_scope="messages[1].content suffix (after project tree)",
122 owner="oc_chat.core.prompt_layers.session_anchors_block",
123 source="sandbox root (_sandbox_root_info) + cwd + git branch (.git/HEAD parser)",
124 cache_policy="Always outside messages[0]; refreshed on session load.",
125 notes="Session self-location block for agent orientation (plan 157).",
126 ),
128 name="chat_turn_user_input",
129 classification="variable",
130 role_or_scope="user history after the context layer",
131 owner="oc_chat.core.session.ChatSession.add_user",
132 source="interactive input, batch input, or API caller text",
133 cache_policy="Per-turn content; never part of the immutable prefix.",
134 notes="The latest user task drives the ReAct turn.",
135 ),
137 name="assistant_and_tool_history",
138 classification="variable",
139 role_or_scope="assistant/tool history",
140 owner="oc_chat.core.react_loop.run_turn",
141 source="assistant replies, tool calls, and tool results",
142 cache_policy="History grows during a turn and across turns.",
143 notes="API shaping may truncate copies, but the session log is full.",
144 ),
146 name="finalize_iteration_nudge",
147 classification="variable",
148 role_or_scope="API-only synthetic user message",
149 owner="oc_chat.core.api_context.messages_for_api",
150 source="FORCE_FINALIZE_USER_MESSAGE",
151 cache_policy="Transient final-iteration nudge; never persisted.",
152 notes="Injected only when inject_finalize_nudge is true.",
153 ),
155 name="sub_loop_result_message",
156 classification="variable",
157 role_or_scope="synthetic user message",
158 owner="oc_chat.core.react_loop.run_turn",
159 source="[Sub-loop result] payload",
160 cache_policy="Per-task result; appended after prompt prefix.",
161 notes="Lets the main loop reason over delegated work output.",
162 ),
164 name="provider_request_options",
165 classification="variable",
166 role_or_scope="request metadata outside messages",
167 owner="oc_chat.core.react_loop.run_turn",
168 source="model, effort, extra_body, tools, max_tokens",
169 cache_policy="Provider/request dependent; not a system prompt layer.",
170 notes="Tracked because it can still affect provider cache keys.",
171 ),
172)
173
174
175def prompt_layer_inventory() -> tuple[PromptLayer, ...]:
176 """Return the canonical C5 prompt-layer inventory."""
177 func = "prompt_layer_inventory"
178 _dbg("OC_CHAT.MAIN", func, f"layers={len(PROMPT_LAYERS)}", 4)
179 return PROMPT_LAYERS
180
181
183 classification: PromptLayerClassification,
184) -> tuple[PromptLayer, ...]:
185 """Return prompt layers with the requested C5 classification."""
186 func = "layers_by_classification"
187 layers = tuple(
188 layer for layer in PROMPT_LAYERS
189 if layer.classification == classification
190 )
191 _dbg("OC_CHAT.MAIN", func, f"{classification}={len(layers)}", 4)
192 return layers
193
194
195# ── Plan 157: session-anchors block ────────────────────────────────────────
196
197
198def _git_branch(root: Path) -> str | None:
199 """Return the current git branch name, or ``None`` when no git repo exists.
200
201 In-process file reads only — no subprocess, no git invocation.
202 Handles three cases:
203
204 * **Normal ``.git/`` directory** — reads ``.git/HEAD`` directly.
205 * **Linked-worktree ``.git`` FILE** — parses the ``gitdir: <path>``
206 indirection line and reads ``HEAD`` from the real git dir.
207 * **Detached HEAD** — returns the short (7-char) hash.
208
209 Returns ``None`` when ``.git`` is missing or unreadable.
210 """
211 dot_git = root / ".git"
212 if not dot_git.exists():
213 return None
214
215 # Linked-worktree case: .git is a FILE containing "gitdir: <path>"
216 if dot_git.is_file():
217 try:
218 content = dot_git.read_text(encoding="utf-8").strip()
219 except OSError:
220 return None
221 if content.startswith("gitdir: "):
222 gitdir = Path(content[len("gitdir: "):].strip())
223 else:
224 return None
225 else:
226 gitdir = dot_git
227
228 head = gitdir / "HEAD"
229 try:
230 head_content = head.read_text(encoding="utf-8").strip()
231 except OSError:
232 return None
233 if not head_content:
234 return None
235
236 # Branch ref: "ref: refs/heads/<name>" → "<name>"
237 if head_content.startswith("ref: refs/heads/"):
238 return head_content[len("ref: refs/heads/"):]
239 # Other symbolic ref (tag, etc.): "ref: refs/tags/<name>" → "refs/tags/<name>"
240 if head_content.startswith("ref: "):
241 return head_content[len("ref: "):]
242 # Detached HEAD — bare SHA hash
243 return head_content[:7]
244
245
247 """Return the ``## Session anchors`` block for the system prompt.
248
249 Composed of three lines:
250
251 * **sandbox root** — effective root path and its source label,
252 obtained from :func:`oc_chat.tools.filesystem._sandbox_root_info`.
253 * **process cwd** — :func:`pathlib.Path.cwd`.
254 * **git branch** — of the SANDBOX ROOT (the anchors' subject — in a
255 worktree launch the cwd has no ``.git`` while the root does), via
256 :func:`_git_branch`, rendered as the branch
257 name, ``(detached: <hash>)``, or ``(no git)``.
258
259 Designed to be injected into ``messages[1]`` after the project tree
260 so every agent session can answer "where am I?" on the first turn.
261 """
262 from oc_chat.tools.filesystem import _sandbox_root_info # noqa: PLC0415
263
264 root_path, label = _sandbox_root_info()
265 cwd = Path.cwd()
266 branch_raw = _git_branch(root_path)
267
268 if branch_raw is None:
269 branch_str = "(no git)"
270 elif len(branch_raw) == 7 and all(c in "0123456789abcdef" for c in branch_raw.lower()):
271 branch_str = f"(detached: {branch_raw})"
272 else:
273 branch_str = branch_raw
274
275 return (
276 "## Session anchors (verify before acting)\n"
277 f"- sandbox root: {root_path} (source: {label})\n"
278 f"- process cwd: {cwd}\n"
279 f"- git branch: {branch_str}"
280 )
281
282
283__all__ = [
284 "PROMPT_LAYERS",
285 "PromptLayer",
286 "PromptLayerClassification",
287 "_git_branch",
288 "layers_by_classification",
289 "prompt_layer_inventory",
290 "session_anchors_block",
291]
tuple[PromptLayer,...] prompt_layer_inventory()
str|None _git_branch(Path root)
tuple[PromptLayer,...] layers_by_classification(PromptLayerClassification classification)