oc_chat
Loading...
Searching...
No Matches
request_plan.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/request_plan.py
4
5"""
6Provider-neutral request IR — core builds it, the provider renders it (plan 135 §3.4).
7
8Purpose
9-------
10Decouple *what* to send (a provider-neutral :class:`ProviderRequestPlan`) from
11*how* a given provider shapes the wire request (:class:`RenderedRequest`,
12produced by ``ProviderPlugin.render_request``). For chat-completions providers
13(DeepSeek/ollama/example) ``session.messages[0]`` stays the single source of
14truth: the default renderer reproduces it **byte-for-byte**. The provider seam
15exists so the future Anthropic bundle (plan 137) can rebuild native ``system``
16blocks from the same plan without touching the inner loop.
17
18Responsibilities
19----------------
20- :class:`ProviderRequestPlan` — the frozen, provider-neutral request IR.
21- :class:`RenderedRequest` — the rendered shape ``render_request`` returns.
22- :func:`build_request_plan` — decompose a chat ``messages`` list (by sentinel,
23 not index) into the IR at the request-assembly point.
24- :func:`compose_chat_messages` — the inverse used by the default OpenAI-Chat
25 renderer; ``policy_prompt + tool_reference`` reproduces ``messages[0]`` exactly.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.CLIENT
30Levels:
31 L2 — lifecycle events
32 L3 — semantic details
33 L4 — deep tracing
34
35Contracts
36---------
37- :func:`build_request_plan` followed by :func:`compose_chat_messages` is a
38 byte-identical round-trip of the chat ``messages`` list (``messages[0]`` is
39 split by pure slicing at the ``\\n\\n## Tools`` boundary, so concatenation
40 always reconstructs the original — even for ``/system``-customised prompts).
41- The dataclasses are frozen; building a plan never mutates the input messages.
42- This module imports no other ``oc_chat`` runtime module at load time (the
43 variable-context sentinel is imported lazily) to stay cycle-free.
44
45Dependencies
46------------
47- oc_diagnostics (structured debug logging via _dbg)
48- oc_chat.core.session (lazy; VARIABLE_CONTEXT_SENTINEL to find the variable-context message)
49"""
50from __future__ import annotations
51
52from dataclasses import dataclass, field
53
54from oc_diagnostics import _dbg
55
56_dbg("OC_CHAT.CLIENT", "request_plan", "module", 4)
57
58#: messages[0] boundary between the policy/persona and the tool-use section.
59#: Both the tools-present and no-tools sections open with ``## Tools`` (policy.py),
60#: and ``build_system_prompt`` joins them with a blank line, so this marker
61#: locates the split. Pure slicing, so concatenation always rebuilds the original.
62_TOOL_SECTION_MARKER = "\n\n## Tools"
63
64
65@dataclass(frozen=True)
67 """Provider-neutral request IR (plan 135 §3.4)."""
68
69 model: str
70 policy_prompt: str # Option C policy/persona (provider-neutral)
71 tool_reference: str # messages[0] tool-use section (preamble+map, or no-tools policy; "" if absent)
72 variable_context: str # messages[1] env+tree+files block ("" if none)
73 conversation: list[dict] # all messages after messages[0], in order
74 tool_catalog: list[dict] = field(default_factory=list) # full schemas for the mode
75 allowed_tools: list[str] | None = None # per-turn subset; None = all in catalog
76 reasoning_mode: str = "off" # "off"|"low"|"medium"|"high"|"max"|"adaptive"
77 output_max_tokens: int | None = None
78 cache_hint: str = "stable_prefix" # provider-neutral cache intent
79
80
81@dataclass(frozen=True)
83 """The provider-rendered request shape (plan 135 §3.4).
84
85 For chat-completions providers ``system`` is ``None`` (the system prompt
86 lives in ``messages[0]``); it is consumed only by the future Anthropic
87 client, which renders native top-level ``system`` blocks (plan 137).
88 """
89
90 messages: list[dict]
91 tools: list[dict] | None = None
92 extra_body: dict | None = None
93 max_tokens: int | None = None
94 system: str | None = None
95
96
97def _tool_name(schema: dict) -> str:
98 """Best-effort tool name from an OpenAI-style or flat schema."""
99 fn = schema.get("function", schema) if isinstance(schema, dict) else {}
100 return str(fn.get("name", "")) if isinstance(fn, dict) else ""
101
102
104 messages: list[dict],
105 model: str,
106 *,
107 tool_catalog: list[dict] | None = None,
108 allowed_tools: list[str] | None = None,
109 reasoning_mode: str = "off",
110 output_max_tokens: int | None = None,
111) -> ProviderRequestPlan:
112 """Decompose a chat ``messages`` list into a :class:`ProviderRequestPlan`.
113
114 ``messages[0]`` is split at the ``## Tools`` boundary into ``policy_prompt`` +
115 ``tool_reference`` (pure slicing → concatenation reproduces it byte-for-byte).
116 ``variable_context`` is the sentinel-marked message's content (informational,
117 for renderers that rebuild); ``conversation`` is every message after
118 ``messages[0]`` in order, so the default chat renderer reproduces the list
119 exactly. Never mutates *messages*.
120 """
121 if not messages:
122 return ProviderRequestPlan(
123 model=model, policy_prompt="", tool_reference="", variable_context="",
124 conversation=[], tool_catalog=list(tool_catalog or []),
125 allowed_tools=allowed_tools, reasoning_mode=reasoning_mode,
126 output_max_tokens=output_max_tokens,
127 )
128
129 # Only messages[0] when it is the system prompt carries the policy/tool
130 # section; otherwise (a system-less call, e.g. a bare user turn) every message
131 # is conversation and there is nothing to decompose.
132 if messages[0].get("role") == "system":
133 s0 = messages[0].get("content") or ""
134 idx = s0.find(_TOOL_SECTION_MARKER)
135 if idx < 0:
136 policy_prompt, tool_reference = s0, ""
137 else:
138 policy_prompt, tool_reference = s0[:idx], s0[idx:]
139 conversation = list(messages[1:])
140 else:
141 policy_prompt, tool_reference = "", ""
142 conversation = list(messages)
143
144 variable_context = ""
145 from oc_chat.core.session import VARIABLE_CONTEXT_SENTINEL # lazy: avoid cycle
146 for m in conversation:
147 if (m.get("content") or "").startswith(VARIABLE_CONTEXT_SENTINEL):
148 variable_context = m["content"]
149 break
150
151 return ProviderRequestPlan(
152 model=model,
153 policy_prompt=policy_prompt,
154 tool_reference=tool_reference,
155 variable_context=variable_context,
156 conversation=conversation,
157 tool_catalog=list(tool_catalog or []),
158 allowed_tools=allowed_tools,
159 reasoning_mode=reasoning_mode,
160 output_max_tokens=output_max_tokens,
161 )
162
163
164def compose_chat_messages(plan: ProviderRequestPlan) -> list[dict]:
165 """Recompose the OpenAI-Chat ``messages`` list from *plan* (default renderer).
166
167 ``policy_prompt + tool_reference`` reproduces the original ``messages[0]``
168 content byte-for-byte; the conversation is appended verbatim. The inverse of
169 :func:`build_request_plan` for chat-completions providers.
170 """
171 system_content = plan.policy_prompt + plan.tool_reference
172 if not system_content:
173 # System-less call (or empty system) — the conversation IS the request.
174 return list(plan.conversation)
175 return [{"role": "system", "content": system_content}, *plan.conversation]
176
177
178def filtered_tool_catalog(plan: ProviderRequestPlan) -> list[dict] | None:
179 """The plan's tool catalog narrowed to ``allowed_tools`` (``None`` = all)."""
180 if not plan.tool_catalog:
181 return None
182 if plan.allowed_tools is None:
183 return plan.tool_catalog
184 names = set(plan.allowed_tools)
185 return [t for t in plan.tool_catalog if _tool_name(t) in names]
186
187
188__all__ = [
189 "ProviderRequestPlan",
190 "RenderedRequest",
191 "build_request_plan",
192 "compose_chat_messages",
193 "filtered_tool_catalog",
194]
str _tool_name(dict schema)
ProviderRequestPlan build_request_plan(list[dict] messages, str model, *, list[dict]|None tool_catalog=None, list[str]|None allowed_tools=None, str reasoning_mode="off", int|None output_max_tokens=None)
list[dict]|None filtered_tool_catalog(ProviderRequestPlan plan)
list[dict] compose_chat_messages(ProviderRequestPlan plan)