oc_chat
Loading...
Searching...
No Matches
agent.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/agent.py
4
5"""
6``Agent`` — one execution unit owning its dependencies, reading its session through ``ctx``.
7
8Purpose
9-------
10Promote what was a god-tuple of arguments (``session``, ``client``,
11``presenter``, ``model_manager``, ``tool_registry``,
12``tool_schemas``, ``ctx``, ``turn_stats``) into a class. The REPL,
13tests, and any future sub-agent worker construct an :class:`Agent`
14once and call :meth:`run_turn` per user input. The same shape works
15for an interactive shell, a :class:`LoggingPresenter`-driven test
16harness, and (when Phase O wants it) a :class:`multiprocessing.Process`
17worker reading :class:`AgentConfig` off a pipe.
18
19Today's job is *not* to ship subprocess plumbing — only to make sure
20the engine objects are constructible from declarative config so that
21adding a serializer later is a small follow-up.
22
23Responsibilities
24----------------
25- :class:`AgentConfig` — the declarative knobs an Agent needs to
26 build its dependencies: which provider, which model, what
27 reasoning effort, optional tool whitelist, optional system prompt
28 override, token profile, ReAct cap.
29- :class:`Agent` — combiner that either accepts pre-built engine
30 objects (``client``, ``model_manager``, ``tool_registry``, ...) or
31 constructs them from :class:`AgentConfig`.
32- :meth:`Agent.run_turn` — the one-call-per-user-input entry point
33 the REPL replaces ``run_turn(...)`` with.
34- :meth:`Agent.compress_if_needed` — wraps
35 :func:`oc_chat.core.context.maybe_compress` so callers don't need
36 to thread the client through.
37- :meth:`Agent.save` — wraps
38 :func:`oc_chat.core.persistence.save_all` for symmetric
39 ergonomics; also closes the presenter if it has a ``close()``
40 method (LoggingPresenter file-descriptor lifecycle).
41
42Diagnostics
43-----------
44Domain: OC_CHAT.MAIN
45Levels:
46 L2 — lifecycle events (Agent built, turn dispatched, saved).
47 L3 — semantic details (tool filter applied, default model used).
48 L4 — deep tracing.
49
50Contracts
51---------
52- The :class:`AgentConfig` is a frozen-ish dataclass — mutating it
53 after :class:`Agent` construction is not supported (the Agent has
54 already cached its derived state). Use a new ``Agent`` to switch
55 providers.
56- :meth:`run_turn` accepts ``user_input: str | None``. When ``str``,
57 the agent appends it as a user message before driving the loop.
58 When ``None``, the loop runs against the session as-is — the path
59 used after a ``/goal`` slash command that already added the user
60 message itself.
61- The agent does *not* own the slash-command :class:`Context`; the
62 REPL sets ``agent.ctx`` and ``agent.turn_stats`` after construction
63 when those features are in play. Headless callers leave them as
64 ``None``.
65- ``tool_filter`` (when set) restricts both ``tool_registry`` and
66 ``tool_schemas`` so the model only sees the whitelisted tools.
67
68Dependencies
69------------
70- oc_diagnostics (structured debug logging via _dbg)
71- oc_chat.core.client_interface (provider-agnostic chat-completions facade)
72- oc_chat.core.context (maybe_compress — context-window compression)
73- oc_chat.core.models (ModelManager and capability lookups)
74- oc_chat.core.persistence (save_all — REPL-exit save wrapper)
75- oc_chat.core.provider_plugin (registry, ProviderPlugin)
76- oc_chat.core.react_loop (run_turn — the per-turn ReAct loop)
77- oc_chat.core.session (ChatSession, CANONICAL_POLICY, helpers)
78- oc_chat.core.tool_registry (build_tool_registry — tool auto-discovery)
79- oc_chat.core.cancellation (lazy; CancellationToken - the per-turn token the SIGINT handler flips)
80- oc_chat.core.commands (lazy; Context - type hint for the attached slash-command context)
81- oc_chat.core.session_transition (lazy; replace_session - the transition seam for a post-attach session assignment)
82- oc_ui.presenter_base (lazy; Presenter - type hints for the presenter)
83- oc_ui.terminal.input (lazy; TurnStats - type hints for the REPL turn statistics)
84"""
85from __future__ import annotations
86
87from dataclasses import dataclass, field
88from typing import TYPE_CHECKING
89
90from oc_diagnostics import _dbg
91
92from oc_chat.core.client_interface import ClientInterface
93from oc_chat.core.context import maybe_compress
94from oc_chat.core.models import ModelManager
95from oc_chat.core.persistence import save_all
96from oc_chat.core import provider_plugin
97from oc_chat.core.react_loop import run_turn as _run_turn
98from oc_chat.core.session import (
99 ChatSession,
100 CANONICAL_POLICY,
101 build_system_prompt,
102)
103from oc_chat.core.tool_registry import build_tool_registry
104
105if TYPE_CHECKING:
106 from oc_chat.core.cancellation import CancellationToken
107 from oc_chat.core.commands import Context
108 from oc_ui.presenter_base import Presenter
109 from oc_ui.terminal.input import TurnStats
110
111
112_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
113
114
115@dataclass
117 """Declarative knobs an :class:`Agent` needs to build its dependencies.
118
119 Carries the minimum information to (re)construct an Agent without
120 handing it pre-built engine objects. The REPL and tests typically
121 pass pre-built objects; future subprocess workers will reconstruct
122 from this config alone.
123 """
124
125 provider_name: str = "deepseek"
126 model: str | None = None # None → use provider.default_model at construction
127 reasoning_effort: str = "high"
128 tool_filter: list[str] | None = None
129 system_prompt: str | None = None
130 # OCT-LINT: disable=no_hardcoded_secrets reason="AgentConfig defaults trip the name heuristic: token_profile ('balanced') is a token-BUDGET profile name, not a credential"
131 token_profile: str = "balanced"
132 max_react_iterations: int | None = None
133
134
136 """Sentinel meaning "build a fresh container" for the manual initialiser.
137
138 Renders as ``<factory>`` so ``inspect.signature(Agent)`` is identical to the
139 dataclass-generated one it replaces — the public ABI is preserved, not just
140 approximated.
141 """
142
143 __slots__ = ()
144
145 def __repr__(self) -> str:
146 return "<factory>"
147
148
149_FACTORY = _FactoryDefault()
150
151
152@dataclass(init=False)
153class Agent:
154 """One self-contained execution unit.
155
156 ``session`` is NOT owned here. Once :attr:`ctx` is attached, :attr:`session`
157 reads THROUGH to ``ctx.session``, which is the single authority (plan 152
158 D1). Before attachment — batch and benchmark callers that never build a
159 ``Context`` — it reads a private backing slot.
160
161 Construct with an :class:`AgentConfig` plus a *presenter*; pass pre-built
162 ``client``/``model_manager``/``tool_registry``/``tool_schemas``/``session``
163 to skip the construction path (the REPL does this; tests usually let the
164 agent build everything).
165
166 The initialiser is written by hand (``@dataclass(init=False)``) so
167 ``session`` can be a property while the public ABI stays exactly what it was:
168 ten positional-or-keyword parameters, two fresh-per-instance containers, and
169 unchanged repr/eq. Narrowing to keyword-only would have been a breaking
170 change to a documented contract with live call sites.
171 """
172
173 config: AgentConfig
174 presenter: "Presenter"
175 session: ChatSession = field(default=None) # type: ignore[assignment]
176 client: ClientInterface = field(default=None) # type: ignore[assignment]
177 model_manager: ModelManager = field(default=None) # type: ignore[assignment]
178 tool_registry: dict = field(default_factory=dict)
179 tool_schemas: list = field(default_factory=list)
180
181 # Slash-command and prompt integration — set by the REPL after
182 # construction; ``None`` for headless callers.
183 ctx: "Context | None" = None
184 turn_stats: "TurnStats | None" = None
185
186 # Phase O3d — the currently-active CancellationToken for the in-
187 # flight turn. ``None`` between turns. The terminal signal handler
188 # in main.py reads this attribute to flip the flag when SIGINT is
189 # received; any presenter implementing a stop button does the same.
190 cancellation: "CancellationToken | None" = None
191
193 self,
194 config: AgentConfig,
195 presenter: "Presenter",
196 session: ChatSession = None, # type: ignore[assignment]
197 client: ClientInterface = None, # type: ignore[assignment]
198 model_manager: ModelManager = None, # type: ignore[assignment]
199 tool_registry: dict = _FACTORY, # type: ignore[assignment]
200 tool_schemas: list = _FACTORY, # type: ignore[assignment]
201 ctx: "Context | None" = None,
202 turn_stats: "TurnStats | None" = None,
203 cancellation: "CancellationToken | None" = None,
204 ) -> None:
205 func = "__init__"
206 self.config = config
207 self.presenter = presenter
208 self.client = client
209 self.model_manager = model_manager
210 # Fresh containers per instance — two Agents must never share these.
211 self.tool_registry = {} if tool_registry is _FACTORY else tool_registry
212 self.tool_schemas = [] if tool_schemas is _FACTORY else tool_schemas
213 self.turn_stats = turn_stats
214 self.cancellation = cancellation
215 # The backing slot is written DIRECTLY here, never through the property:
216 # construction is not a session transition, and routing it through the
217 # seam would take the entry lease (or be rejected) mid-``__init__``.
218 self._session_slot: ChatSession = session
219 self.ctx = ctx
220
221 cfg = self.config
222
223 # Engine objects: build from config when not supplied.
224 if self.client is None or self.model_manager is None:
225 provider = provider_plugin.registry.get(cfg.provider_name)
226 if self.client is None:
227 self.client = ClientInterface(provider)
228 if self.model_manager is None:
229 self.model_manager = ModelManager(provider)
230
231 # Tool registry: auto-discover when not supplied.
232 if not self.tool_registry and not self.tool_schemas:
233 self.tool_registry, self.tool_schemas = build_tool_registry()
234
235 # Tool filter: restrict both registry and schemas to the whitelist.
236 if cfg.tool_filter is not None:
237 allow = set(cfg.tool_filter)
238 self.tool_registry = {
239 k: v for k, v in self.tool_registry.items() if k in allow
240 }
241 self.tool_schemas = [
242 s for s in self.tool_schemas
243 if s.get("function", {}).get("name") in allow
244 ]
245 _dbg("OC_CHAT.MAIN", func,
246 f"tool_filter applied: {len(self.tool_registry)} tool(s) remaining", 3)
247
248 # Session: build from config when not supplied. Apply the system
249 # prompt override (or default + embedded tool schemas), and the
250 # session-level knobs from config. Writes go to the backing SLOT, not
251 # through the property — see the note above ``self._session_slot``.
252 if self._session_slot is None:
253 base_system = cfg.system_prompt or CANONICAL_POLICY
254 system_text = build_system_prompt(base_system, self.tool_schemas)
255 model = cfg.model or self.model_manager.provider.default_model
257 model=model,
258 reasoning_effort=cfg.reasoning_effort,
259 token_profile=cfg.token_profile,
260 messages=[{"role": "system", "content": system_text}],
261 )
262 self._session_slot.max_react_iterations = cfg.max_react_iterations
263 _dbg("OC_CHAT.MAIN", func,
264 f"new session={self._session_slot.name} model={model}", 2)
265 else:
266 _dbg("OC_CHAT.MAIN", func,
267 f"using supplied session={self._session_slot.name}", 2)
268
269 # ── Session identity: read-through to the single authority (D1) ────────
270
271 @property
272 def session(self) -> ChatSession:
273 """The active session — ``ctx.session`` once a context is attached.
274
275 Reading through is what makes ``agent.session is ctx.session`` true for
276 EVERY path, so a turn can no longer run against a session the UI has
277 already replaced. Before attachment (batch / benchmark callers that
278 never build a ``Context``) this reads the private backing slot.
279 """
280 ctx = getattr(self, "ctx", None)
281 if ctx is not None:
282 return ctx.session # type: ignore[return-value]
283 return self._session_slot
284
285 @session.setter
286 def session(self, value: ChatSession) -> None:
287 """Set the active session.
288
289 PRE-attach this writes the backing slot — the batch and benchmark
290 entrypoints construct an Agent, assign a session, and only later (or
291 never) attach a context, and those writes are slot writes by design.
292
293 POST-attach it routes through the transition seam, because a bare
294 ``ctx.session =`` re-derives none of the dependent state. The seam takes
295 the entry lease, so an off-main-thread, mid-turn or reentrant assignment
296 is REJECTED with ``TransitionRejectedError`` and has zero effects —
297 enforced here rather than trusted to the caller, since this setter is
298 reachable from arbitrary code.
299 """
300 ctx = getattr(self, "ctx", None)
301 if ctx is None:
302 self._session_slot = value
303 return
304 from oc_chat.core.session_transition import replace_session
305 replace_session(ctx, value)
306
307 # ── Per-turn driver ────────────────────────────────────────────────────
308
309 def run_turn(self, user_input: str | None = None) -> None:
310 """Drive one ReAct turn against the session.
311
312 When *user_input* is a non-empty string, append it as a user
313 message before running the loop. When ``None``, run the loop
314 against the session as-is — the path used after a ``/goal``
315 slash command that already added the user message itself.
316
317 Owns a fresh :class:`CancellationToken` for the duration of the
318 turn (Phase O3d). Stored on ``self.cancellation`` so the SIGINT
319 handler in main.py can flip it. Cleared in the ``finally``
320 block so subsequent turns start with a clean slate.
321 """
322 from oc_chat.core.cancellation import CancellationToken
324 try:
325 if user_input is not None:
326 self.session.add_user(user_input)
327 _run_turn(
328 self.session,
329 self.client,
330 self.presenter,
331 self.model_manager,
332 self.tool_registry,
333 self.tool_schemas,
334 ctx=self.ctx,
335 turn_stats=self.turn_stats,
336 cancellation=self.cancellation,
337 )
338 finally:
339 self.cancellation = None
340
341 # ── Lifecycle helpers ──────────────────────────────────────────────────
342
343 def compress_if_needed(self) -> bool:
344 """Trigger :func:`maybe_compress` against the agent's session.
345
346 Returns ``True`` when compression actually ran. The REPL calls
347 this between user input and :meth:`run_turn` so the prompt
348 stays under the model's context window.
349 """
350 return maybe_compress(self.session, self.client, self.presenter)
351
352 def save(self) -> None:
353 """Persist session metadata and chat log; close the presenter.
354
355 Wraps :func:`save_all` with the presenter-close step so the
356 REPL's ``finally`` block becomes a single call. Headless
357 callers can skip ``save`` entirely by calling
358 :func:`save_all` directly when they want only persistence.
359 """
360 if hasattr(self.presenter, "close"):
361 self.presenter.close()
362 save_all(self.session)
ModelManager model_manager
Definition agent.py:177
ChatSession session
Definition agent.py:175
AgentConfig presenter
Definition agent.py:174
None __init__(self, AgentConfig config, "Presenter" presenter, ChatSession session=None, ClientInterface client=None, ModelManager model_manager=None, dict tool_registry=_FACTORY, list tool_schemas=_FACTORY, "Context | None" ctx=None, "TurnStats | None" turn_stats=None, "CancellationToken | None" cancellation=None)
Definition agent.py:204
bool compress_if_needed(self)
Definition agent.py:343
ChatSession _session_slot
Definition agent.py:218
ClientInterface client
Definition agent.py:176
None run_turn(self, str|None user_input=None)
Definition agent.py:309