oc_chat
Loading...
Searching...
No Matches
sub_loop.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/sub_loop.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/unit/test_sub_loop_hygiene.py"
6"""
7Headless mini-ReAct engine for offloading cheap tasks out of the main loop.
8
9Purpose
10-------
11:func:`run_sub_loop` runs a self-contained, headless ReAct loop with its
12own minimal conversation context and a fixed model. The sub-loop has full
13access to the tools registry, logs every iteration to a session artifact,
14and returns **only the final assistant text** — the main loop never sees
15the sub-loop's internal tool calls or intermediate reasoning.
16
17This lets the main loop preserve its prefix cache (the main model never
18changes mid-turn) while still routing cheap tasks (reads, lookups) to a
19cheaper model like ``deepseek-v4-flash``.
20
21Responsibilities
22----------------
23- :func:`run_sub_loop` — construct a minimal conversation, run ≤N
24 ReAct iterations, stream from the API, dispatch tool calls, archive
25 the full sub-loop log, return the final answer.
26- ``_DEFAULT_SUB_SYSTEM`` — task-focused framing combined with
27 ``CANONICAL_POLICY`` + the compact tool map to compose the default
28 sub-agent prompt per call (plan 135 §3.8).
29
30Diagnostics
31-----------
32Domain: OC_CHAT.SUB_LOOP
33Levels:
34 L2 — lifecycle (spawn / complete / cap hit / error)
35 L3 — per-iteration summary
36 L4 — full tool-call dump
37
38Contracts
39---------
40- The sub-loop never mutates the caller's session or message list.
41- The task description must be self-contained — no dependency on
42 conversation history, project tree, or AGENT_CONTEXT.
43- Returns a :class:`SubLoopResult` (full text + merged usage + counts +
44 archive path). The caller injects ``.text`` into the main conversation
45 as a (bounded) user message and folds ``.usage`` into session cost.
46- If the iteration cap is reached, the last assistant message is
47 returned with a ``[SUB_LOOP_CAP]`` prefix.
48
49Dependencies
50------------
51- oc_diagnostics (structured debug logging via _dbg)
52- oc_chat.core.persistence (lazy; session artifact logging)
53- oc_chat.core.event_bus (default_bus for synchronous M2 event publication)
54- oc_chat.core.events (make_subagent_* TurnEvent factories and new_event_id)
55- oc_chat.core.client_interface (lazy; ClientInterface - the abstract streaming API)
56- oc_chat.core.cancellation (CancellationError - a cancelled sub-loop stream, and CancellationToken for type hints)
57- oc_chat.core.policy (lazy; CANONICAL_POLICY - the base of the default sub-agent prompt)
58- oc_chat.core.session (lazy; build_system_prompt - adds the tools section to the default sub-agent prompt)
59- oc_chat.tools._sandbox (lazy; redact_secrets - redacts tool results before the sub-model context and the archive)
60- oc_chat.tools.shell (lazy; deny_unconfirmed_tier2 - fails a gated action closed in the headless sub-loop)
61
62See Also
63--------
64oc_chat/docs/conventions/CONTRACTS.md §5 Streaming chunk tuples — the
65chunk_type / data contract that stream_chat yields.
66"""
67
68from __future__ import annotations
69
70import json
71from dataclasses import dataclass, field
72from datetime import datetime
73from typing import Any, TYPE_CHECKING
74
75from oc_chat.core.cancellation import CancellationError
76from oc_chat.core.event_bus import default_bus
77from oc_chat.core.events import (
78 make_subagent_completed,
79 make_subagent_started,
80 new_event_id,
81)
82
83if TYPE_CHECKING:
84 from oc_chat.core.cancellation import CancellationToken
85
86from oc_diagnostics import _dbg
87
88# Lazy imports to avoid circular dependency at module level
89
90# Plan 135 §3.8: the short task-focused framing appended to CANONICAL_POLICY when
91# a caller does not supply an explicit ``system_prompt``. The full default prompt
92# is composed PER CALL inside :func:`run_sub_loop` (CANONICAL_POLICY + this framing
93# + the conditional tools section / compact map for the sub-loop's tool set) — a
94# per-call compact map cannot live in a module constant. The env block is
95# deliberately excluded (the sub-loop is headless / cache-isolated by design).
96_DEFAULT_SUB_SYSTEM = (
97 "You are a task-focused sub-agent. "
98 "Complete the assigned task using available tools. "
99 "Return ONLY the requested information — no commentary, no extra text."
100)
101
102MAX_SUB_ITERATIONS = 5
103SUB_LOOP_CAP_MARKER = "[SUB_LOOP_CAP] "
104
105
106def _build_default_sub_system(tool_schemas: list) -> str:
107 """Compose the default sub-agent system prompt (plan 135 §3.8).
108
109 ``CANONICAL_POLICY`` + the task-focused framing + the conditional tools
110 section / compact map for *tool_schemas* (the no-tools policy when the set is
111 empty). The env block is deliberately excluded — the sub-loop is headless and
112 cache-isolated. Stays small (no JSON-schema dump; schemas travel via ``tools``).
113 """
114 from oc_chat.core.policy import CANONICAL_POLICY
115 from oc_chat.core.session import build_system_prompt
116
117 return build_system_prompt(f"{CANONICAL_POLICY}\n\n{_DEFAULT_SUB_SYSTEM}", tool_schemas)
118
119
120@dataclass
122 """Structured return of :func:`run_sub_loop` (plan 134 B2).
123
124 The sub-loop used to return a bare ``str``; that hid its cost from the
125 parent turn. Every exit path now returns this — including the early
126 cancellation / API-error / cap-hit paths — so the parent can fold the
127 sub-loop's token spend into session cost + the live display, and build
128 A4's truncation marker from :attr:`artifact_path`.
129
130 Fields
131 ------
132 text:
133 The final assistant reply (full, untruncated — the parent bounds it
134 at the injection site). Cap/cancel/error texts carry their markers.
135 usage:
136 Merged token counters summed across inner iterations (named fields
137 only, types kept distinct), or ``None`` if no usage chunk arrived.
138 iterations:
139 Inner ReAct iterations entered.
140 tool_calls:
141 Total tool calls dispatched across the sub-loop.
142 artifact_path:
143 Absolute path of the archived full transcript, or ``None`` when
144 archiving is off (``session_name is None``) — the marker then omits
145 the path.
146 unknown_usage_raw:
147 Provider usage fields the merge did not recognise — carried raw
148 (never folded into cost math). The **parent** emits the throttled
149 "new usage field" warning, so the sub-loop needs no session handle.
150 """
151
152 text: str
153 usage: dict | None = None
154 iterations: int = 0
155 tool_calls: int = 0
156 artifact_path: str | None = None
157 unknown_usage_raw: list[dict] = field(default_factory=list)
158
159
160# Token-usage fields summed across sub-loop iterations. Types are kept
161# distinct (prices differ per type); anything outside this set is carried raw
162# (see :func:`_merge_usage`) so a new provider field is handled deliberately
163# rather than silently folded into a cost number.
164_KNOWN_USAGE_SCALARS = (
165 "prompt_tokens",
166 "completion_tokens",
167 "total_tokens",
168 "prompt_cache_hit_tokens",
169 "prompt_cache_miss_tokens",
170)
171_KNOWN_USAGE_DETAILS = ("prompt_tokens_details", "completion_tokens_details")
172_KNOWN_PROMPT_DETAILS = ("cached_tokens", "audio_tokens", "cache_write_tokens")
173_KNOWN_COMPLETION_DETAILS = (
174 "reasoning_tokens",
175 "audio_tokens",
176 "accepted_prediction_tokens",
177 "rejected_prediction_tokens",
178)
179
180
182 merged: dict[str, Any],
183 new: Any,
184 *,
185 model: str,
186 unknown_raw: list[dict[str, Any]],
187) -> None:
188 """Accumulate one provider usage dict into *merged* (in place).
189
190 Named scalar token fields and the closed vocabulary of numeric detail fields
191 are summed. ``None`` for a known scalar or any detail sub-field means absent
192 and is skipped silently. A novel detail name, any non-null non-numeric value,
193 or an unrecognised top-level field is appended to *unknown_raw* as
194 ``{field, value, model}`` and is **never** folded into cost math.
195 """
196 if not isinstance(new, dict):
197 return
198 for key, val in new.items():
199 if key in _KNOWN_USAGE_SCALARS:
200 if val is None:
201 continue
202 if isinstance(val, (int, float)) and not isinstance(val, bool):
203 merged[key] = merged.get(key, 0) + val
204 else:
205 unknown_raw.append({"field": key, "value": val, "model": model})
206 elif key in _KNOWN_USAGE_DETAILS:
207 if isinstance(val, dict):
208 dst = merged.setdefault(key, {})
209 known_details = (
210 _KNOWN_PROMPT_DETAILS
211 if key == "prompt_tokens_details"
212 else _KNOWN_COMPLETION_DETAILS
213 )
214 for sub, sval in val.items():
215 if sval is None:
216 continue
217 if (
218 sub in known_details
219 and isinstance(sval, (int, float))
220 and not isinstance(sval, bool)
221 ):
222 dst[sub] = dst.get(sub, 0) + sval
223 else:
224 unknown_raw.append(
225 {"field": f"{key}.{sub}", "value": sval, "model": model})
226 else:
227 unknown_raw.append({"field": key, "value": val, "model": model})
228 else:
229 unknown_raw.append({"field": key, "value": val, "model": model})
230
231
232def _now_iso() -> str:
233 """Millisecond-precision local ISO-8601 timestamp for event envelopes (M2.4)."""
234 return datetime.now().astimezone().isoformat(timespec="milliseconds")
235
236
238 client: "ClientInterface", # noqa: F821
239 task: str,
240 model: str,
241 tool_registry: dict,
242 tool_schemas: list,
243 *,
244 system_prompt: str | None = None,
245 max_iterations: int = MAX_SUB_ITERATIONS,
246 session_name: str | None = None,
247 max_tokens: int | None = None,
248 presenter: object | None = None,
249 cancellation: "CancellationToken | None" = None,
250 parent_event_id: str | None = None,
251 parent_session_id: str | None = None,
252) -> SubLoopResult:
253 """Execute a headless mini-ReAct loop and return a :class:`SubLoopResult`.
254
255 Parameters
256 ----------
257 client:
258 The same :class:`ClientInterface` the main loop uses.
259 task:
260 A self-contained description of what the sub-agent should do.
261 model:
262 Model ID to use (e.g. ``deepseek-v4-flash``).
263 tool_registry:
264 ``{name: callable}`` dict from the main loop.
265 tool_schemas:
266 OpenAI tool schemas for the API ``tools`` parameter.
267 system_prompt:
268 Explicit system prompt, used verbatim. When ``None`` (the default),
269 run_sub_loop composes one per call: ``CANONICAL_POLICY`` + the
270 task-focused framing + the conditional tools section / compact map for
271 ``tool_schemas`` (plan 135 §3.8). No env block (headless by design).
272 max_iterations:
273 Maximum ReAct iterations (default 5).
274 session_name:
275 Passed through to artifact archiving. ``None`` disables.
276 max_tokens:
277 Completion token limit (default: model default).
278
279 Returns
280 -------
281 SubLoopResult
282 The final answer (or a ``[SUB_LOOP_CAP]`` / ``[SUB_LOOP_ERROR]``
283 marker) plus the merged token usage, iteration / tool-call counts,
284 the archive path, and any unrecognised usage fields. Returned on
285 **every** exit path except ``KeyboardInterrupt``, which re-raises so
286 the parent's handler records cost and the REPL backstop recovers.
287 """
288 from oc_chat.core.client_interface import ClientInterface
289 from oc_chat.core.persistence import save_tool_artifact
290
291 func = "run_sub_loop"
292 _dbg("OC_CHAT.SUB_LOOP", func, f"spawn model={model} task={task[:80]}…", 2)
293
294 # Plan 135 §3.8: compose the default sub-agent prompt per call so it carries
295 # the compact map for THIS sub-loop's tool set. An explicit system_prompt is
296 # used verbatim (not overridden).
297 if system_prompt is None:
298 system_prompt = _build_default_sub_system(tool_schemas)
299
300 # M2.4: typed sub-agent events. Gated on both parent ids so legacy
301 # callers and unit tests that omit them emit nothing (plan 102 §4.1).
302 # subagent_session_id is fresh + stable across the started/completed pair;
303 # parent_event_id is the parent turn's turn_id (ADR-1), reused as turn_id.
304 _events_enabled = parent_event_id is not None and parent_session_id is not None
305 _subagent_sid = new_event_id()
306
307 def _emit(make: Any, **kw: Any) -> None:
308 if not _events_enabled:
309 return
310 default_bus().publish(make(
311 event_id=new_event_id(), parent_event_id=parent_event_id,
312 session_id=parent_session_id, ts=_now_iso(), turn_id=parent_event_id,
313 subagent_session_id=_subagent_sid, **kw))
314
315 _emit(make_subagent_started)
316
317 # ── Build minimal conversation ──────────────────────────────────
318 messages: list[dict[str, Any]] = [
319 {"role": "system", "content": system_prompt.strip()},
320 {"role": "user", "content": task},
321 ]
322 log_archive: list[dict[str, Any]] = [] # full transcript for artifact
323
324 # ── Loop-scoped accounting (plan 134 B2 / Opus M2) ──────────────
325 # These live OUTSIDE the iteration loop so every early-exit path can
326 # return the partial usage that arrived before the exit; per-iteration
327 # locals reset and would be lost.
328 merged_usage: dict[str, Any] = {}
329 unknown_usage_raw: list[dict[str, Any]] = []
330 total_tool_calls = 0
331 iteration = 0
332 assistant_msg: dict[str, Any] = {}
333
334 # ── Mini ReAct loop ─────────────────────────────────────────────
335 for iteration in range(max_iterations):
336 _dbg("OC_CHAT.SUB_LOOP", func,
337 f"iter {iteration}/{max_iterations} model={model}", 3)
338
339 full_content = ""
340 full_reasoning = ""
341 finish_reason: str | None = None # None == provider reported none
342 tool_calls_flat: list[dict] = [] # provider {id,name,arguments} for dispatch
343
344 # Phase H.4a-iii: bracket the inner stream with a spinner. Stops on
345 # first yielded chunk to avoid overlap with streamed text; finally
346 # block is a belt-and-braces safety net (spinner_stop is idempotent).
347 _spinner = presenter.spinner_start("thinking (sub-loop)…") if presenter is not None else None
348
349 def _partial(text: str) -> SubLoopResult:
350 """Build a SubLoopResult for an early-exit path, preserving usage."""
351 return SubLoopResult(
352 text=text,
353 usage=merged_usage or None,
354 iterations=iteration + 1,
355 tool_calls=total_tool_calls,
356 artifact_path=None,
357 unknown_usage_raw=unknown_usage_raw,
358 )
359
360 try:
361 for chunk_type, data in client.stream_chat(
362 messages,
363 model,
364 tools=tool_schemas,
365 max_tokens=max_tokens,
366 cancellation=cancellation,
367 ):
368 if _spinner is not None and presenter is not None:
369 presenter.spinner_stop(_spinner)
370 if chunk_type == "reasoning":
371 full_reasoning += data
372 elif chunk_type == "content":
373 full_content += data
374 elif chunk_type == "tool_call":
375 # Providers stream tool calls ONE at a time (singular —
376 # plan 134 A1). Accumulate them like the main loop does;
377 # the old plural "tool_calls" branch never matched in
378 # production, so sub-loop tools were silently dropped.
379 tool_calls_flat.append(data)
380 elif chunk_type == "usage":
381 # Merge across iterations so the parent can fold the
382 # sub-loop's token spend into session cost (B2).
383 _merge_usage(merged_usage, data,
384 model=model, unknown_raw=unknown_usage_raw)
385 elif chunk_type == "finish_reason":
386 finish_reason = data
387 except CancellationError:
388 # Cooperative cancel from a sister presenter or signal handler.
389 _dbg("OC_CHAT.SUB_LOOP", func,
390 "sub-loop stream cancelled via CancellationToken", 2, override=True)
391 _emit(make_subagent_completed, outcome="cancelled") # M2.4 ADR-4
392 return _partial(SUB_LOOP_CAP_MARKER + "Sub-loop cancelled.")
393 except KeyboardInterrupt:
394 # Propagate cleanly to the REPL backstop (main.py). A single path
395 # cannot both return a SubLoopResult and re-raise; the parent's
396 # interrupt handler records partial cost and recovers (plan 134
397 # H3). Folding interrupt usage here is a deferred refactor.
398 _dbg("OC_CHAT.SUB_LOOP", func,
399 "sub-loop stream cancelled by user (Ctrl+C)", 2, override=True)
400 _emit(make_subagent_completed, outcome="cancelled") # M2.4 ADR-4
401 raise
402 except Exception as e:
403 _dbg("OC_CHAT.SUB_LOOP", func,
404 f"API error: {type(e).__name__}: {e}", 1, override=True)
405 _emit(make_subagent_completed, outcome="error") # M2.4 ADR-4
406 return _partial(f"[SUB_LOOP_ERROR: {type(e).__name__}: {e}]")
407 finally:
408 # Phase H.4a-iii: safety stop in case no chunks ever arrived
409 # (or first-chunk stop somehow missed). Idempotent.
410 if _spinner is not None and presenter is not None:
411 presenter.spinner_stop(_spinner)
412
413 # A truncated sub-loop hands the parent a partial answer that reads
414 # like a finished one — headless, so nobody sees it happen. Log it.
415 if finish_reason == "length":
416 _dbg("OC_CHAT.SUB_LOOP", func,
417 f"TRUNCATED at max_tokens: iter={iteration} model={model} "
418 f"max_tokens={max_tokens} content={len(full_content)}c "
419 f"reasoning={len(full_reasoning)}c "
420 f"tool_calls={len(tool_calls_flat)}", 1, override=True)
421
422 # Record assistant message. tool_calls are stored in the NESTED API
423 # schema ({id, type, function:{name, arguments}}) — the flat provider
424 # shape would be rejected when re-sent on the next iteration (plan 134
425 # A1 (ii)). The flat records (tool_calls_flat) drive dispatch below.
426 api_tool_calls = [
427 {
428 "id": tc.get("id", tc.get("name", "?")),
429 "type": "function",
430 "function": {
431 "name": tc.get("name"),
432 "arguments": tc.get("arguments", ""),
433 },
434 }
435 for tc in tool_calls_flat
436 ]
437 assistant_msg = {"role": "assistant", "content": full_content or None}
438 if full_reasoning:
439 assistant_msg["reasoning_content"] = full_reasoning
440 if api_tool_calls:
441 assistant_msg["tool_calls"] = api_tool_calls
442 messages.append(assistant_msg)
443 log_archive.append(dict(assistant_msg))
444
445 # ── No tool calls → this is the final answer ────────────────
446 if not tool_calls_flat:
447 _dbg("OC_CHAT.SUB_LOOP", func, "final answer received", 2)
448 break
449
450 # ── Execute tool calls (sequential dispatch) ────────────────
451 total_tool_calls += len(tool_calls_flat)
452 for tc in tool_calls_flat:
453 tool_fn = tool_registry.get(tc.get("name", ""))
454 if not tool_fn:
455 result = {"error": f"Unknown tool: {tc.get('name')}"}
456 else:
457 try:
458 args = json.loads(tc.get("arguments", "{}"))
459 result = tool_fn(**args)
460 except Exception as e:
461 _dbg("OC_CHAT.SUB_LOOP", func,
462 f"tool {tc.get('name')} raised {type(e).__name__}: {e}",
463 1, override=True)
464 result = {"error": str(e)}
465
466 # M8.0-C / F67: a sub-loop is always headless -- no human to confirm a
467 # gated action (a Tier-2 command or an external-content fetch). Fail
468 # closed: convert any `requires_confirmation` to a terminal error so the
469 # sub-agent stops asking (it can never self-authorize). The interactive
470 # confirm channels live only in the main react-loop.
471 if isinstance(result, dict) and result.get("requires_confirmation"):
472 from oc_chat.tools import shell
473 result = shell.deny_unconfirmed_tier2(
474 result, note="A sub-agent runs headless and cannot confirm a gated action.")
475 _dbg("OC_CHAT.SUB_LOOP", func,
476 "gated action denied headless (sub-loop): requires_confirmation", 2, override=True)
477
478 # Convert result dict to string for the API. M8.0 WU-9 review fix
479 # (F46/F51): redact secret patterns before the result enters the
480 # sub-model context AND the on-disk archive -- recall returns memory
481 # content unredacted (the filesystem tools redact at the tool level).
482 from oc_chat.tools._sandbox import redact_secrets
483 result_str = redact_secrets(json.dumps(result, ensure_ascii=False, default=str))
484 result_msg = {
485 "role": "tool",
486 "tool_call_id": tc.get("id", tc.get("name", "?")),
487 "content": result_str,
488 }
489 messages.append(result_msg)
490 log_archive.append(dict(result_msg))
491
492 else:
493 # Loop completed without break → hit iteration cap
494 _dbg("OC_CHAT.SUB_LOOP", func,
495 f"cap hit after {max_iterations} iterations", 2)
496 _emit(make_subagent_completed, outcome="cap_hit") # M2.4: both cap returns
497 if messages and messages[-1].get("role") == "assistant":
498 last = messages[-1].get("content") or ""
499 cap_text = SUB_LOOP_CAP_MARKER + last
500 else:
501 cap_text = SUB_LOOP_CAP_MARKER + "no assistant reply"
502 return SubLoopResult(
503 text=cap_text,
504 usage=merged_usage or None,
505 iterations=max_iterations,
506 tool_calls=total_tool_calls,
507 artifact_path=None,
508 unknown_usage_raw=unknown_usage_raw,
509 )
510
511 # ── Extract final answer ────────────────────────────────────────
512 # The full text is returned verbatim. The chain-of-thought line "filter"
513 # (plan 134 A2) and the silent 10k truncation (A4) are gone — the former
514 # mangled legitimate answers ("First,", "Step 1:" …); the latter is
515 # replaced by the parent bounding the injected message at the model's
516 # tool-result cap (react_loop / resolve_tool_result_cap). Sub-agent
517 # terseness is the system prompt's job, not a regex.
518 final_text = (assistant_msg.get("content") or "").strip()
519
520 # M2.4: happy-path completion emitted before the archive side-effect (ADR-3)
521 # so it fires whether or not session_name is set, and even if archiving fails.
522 _emit(make_subagent_completed, outcome="ok")
523
524 # ── Archive full transcript ─────────────────────────────────────
525 artifact_path: str | None = None
526 if session_name is not None:
527 archive_entry = {
528 "sub_loop": {
529 "model": model,
530 "task": task,
531 "iterations": iteration + 1,
532 "max_iterations": max_iterations,
533 "final_length": len(final_text),
534 },
535 "messages": log_archive,
536 }
537 archive_json = json.dumps(archive_entry, ensure_ascii=False, default=str)
538 artifact_path = str(save_tool_artifact(
539 session_name, f"sub_loop_{model}", archive_json))
540 _dbg("OC_CHAT.SUB_LOOP", func,
541 f"done model={model} iters={iteration+1} chars={len(final_text)}", 2)
542 return SubLoopResult(
543 text=final_text,
544 usage=merged_usage or None,
545 iterations=iteration + 1,
546 tool_calls=total_tool_calls,
547 artifact_path=artifact_path,
548 unknown_usage_raw=unknown_usage_raw,
549 )
SubLoopResult run_sub_loop("ClientInterface" client, str task, str model, dict tool_registry, list tool_schemas, *, str|None system_prompt=None, int max_iterations=MAX_SUB_ITERATIONS, str|None session_name=None, int|None max_tokens=None, object|None presenter=None, "CancellationToken | None" cancellation=None, str|None parent_event_id=None, str|None parent_session_id=None)
Definition sub_loop.py:252
None _merge_usage(dict[str, Any] merged, Any new, *, str model, list[dict[str, Any]] unknown_raw)
Definition sub_loop.py:187
str _build_default_sub_system(list tool_schemas)
Definition sub_loop.py:106