182 merged: dict[str, Any],
186 unknown_raw: list[dict[str, Any]],
188 """Accumulate one provider usage dict into *merged* (in place).
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.
196 if not isinstance(new, dict):
198 for key, val
in new.items():
199 if key
in _KNOWN_USAGE_SCALARS:
202 if isinstance(val, (int, float))
and not isinstance(val, bool):
203 merged[key] = merged.get(key, 0) + val
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, {})
210 _KNOWN_PROMPT_DETAILS
211 if key ==
"prompt_tokens_details"
212 else _KNOWN_COMPLETION_DETAILS
214 for sub, sval
in val.items():
219 and isinstance(sval, (int, float))
220 and not isinstance(sval, bool)
222 dst[sub] = dst.get(sub, 0) + sval
225 {
"field": f
"{key}.{sub}",
"value": sval,
"model": model})
227 unknown_raw.append({
"field": key,
"value": val,
"model": model})
229 unknown_raw.append({
"field": key,
"value": val,
"model": model})
238 client:
"ClientInterface",
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,
253 """Execute a headless mini-ReAct loop and return a :class:`SubLoopResult`.
258 The same :class:`ClientInterface` the main loop uses.
260 A self-contained description of what the sub-agent should do.
262 Model ID to use (e.g. ``deepseek-v4-flash``).
264 ``{name: callable}`` dict from the main loop.
266 OpenAI tool schemas for the API ``tools`` parameter.
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).
273 Maximum ReAct iterations (default 5).
275 Passed through to artifact archiving. ``None`` disables.
277 Completion token limit (default: model default).
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.
291 func =
"run_sub_loop"
292 _dbg(
"OC_CHAT.SUB_LOOP", func, f
"spawn model={model} task={task[:80]}…", 2)
297 if system_prompt
is None:
304 _events_enabled = parent_event_id
is not None and parent_session_id
is not None
305 _subagent_sid = new_event_id()
307 def _emit(make: Any, **kw: Any) ->
None:
308 if not _events_enabled:
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))
315 _emit(make_subagent_started)
318 messages: list[dict[str, Any]] = [
319 {
"role":
"system",
"content": system_prompt.strip()},
320 {
"role":
"user",
"content": task},
322 log_archive: list[dict[str, Any]] = []
328 merged_usage: dict[str, Any] = {}
329 unknown_usage_raw: list[dict[str, Any]] = []
332 assistant_msg: dict[str, Any] = {}
335 for iteration
in range(max_iterations):
336 _dbg(
"OC_CHAT.SUB_LOOP", func,
337 f
"iter {iteration}/{max_iterations} model={model}", 3)
341 finish_reason: str |
None =
None
342 tool_calls_flat: list[dict] = []
347 _spinner = presenter.spinner_start(
"thinking (sub-loop)…")
if presenter
is not None else None
349 def _partial(text: str) -> SubLoopResult:
350 """Build a SubLoopResult for an early-exit path, preserving usage."""
353 usage=merged_usage
or None,
354 iterations=iteration + 1,
355 tool_calls=total_tool_calls,
357 unknown_usage_raw=unknown_usage_raw,
361 for chunk_type, data
in client.stream_chat(
365 max_tokens=max_tokens,
366 cancellation=cancellation,
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":
374 elif chunk_type ==
"tool_call":
379 tool_calls_flat.append(data)
380 elif chunk_type ==
"usage":
384 model=model, unknown_raw=unknown_usage_raw)
385 elif chunk_type ==
"finish_reason":
387 except CancellationError:
389 _dbg(
"OC_CHAT.SUB_LOOP", func,
390 "sub-loop stream cancelled via CancellationToken", 2, override=
True)
391 _emit(make_subagent_completed, outcome=
"cancelled")
392 return _partial(SUB_LOOP_CAP_MARKER +
"Sub-loop cancelled.")
393 except KeyboardInterrupt:
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")
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")
406 return _partial(f
"[SUB_LOOP_ERROR: {type(e).__name__}: {e}]")
410 if _spinner
is not None and presenter
is not None:
411 presenter.spinner_stop(_spinner)
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)
428 "id": tc.get(
"id", tc.get(
"name",
"?")),
431 "name": tc.get(
"name"),
432 "arguments": tc.get(
"arguments",
""),
435 for tc
in tool_calls_flat
437 assistant_msg = {
"role":
"assistant",
"content": full_content
or None}
439 assistant_msg[
"reasoning_content"] = full_reasoning
441 assistant_msg[
"tool_calls"] = api_tool_calls
442 messages.append(assistant_msg)
443 log_archive.append(dict(assistant_msg))
446 if not tool_calls_flat:
447 _dbg(
"OC_CHAT.SUB_LOOP", func,
"final answer received", 2)
451 total_tool_calls += len(tool_calls_flat)
452 for tc
in tool_calls_flat:
453 tool_fn = tool_registry.get(tc.get(
"name",
""))
455 result = {
"error": f
"Unknown tool: {tc.get('name')}"}
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}",
464 result = {
"error": str(e)}
471 if isinstance(result, dict)
and result.get(
"requires_confirmation"):
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)
483 result_str = redact_secrets(json.dumps(result, ensure_ascii=
False, default=str))
486 "tool_call_id": tc.get(
"id", tc.get(
"name",
"?")),
487 "content": result_str,
489 messages.append(result_msg)
490 log_archive.append(dict(result_msg))
494 _dbg(
"OC_CHAT.SUB_LOOP", func,
495 f
"cap hit after {max_iterations} iterations", 2)
496 _emit(make_subagent_completed, outcome=
"cap_hit")
497 if messages
and messages[-1].get(
"role") ==
"assistant":
498 last = messages[-1].get(
"content")
or ""
499 cap_text = SUB_LOOP_CAP_MARKER + last
501 cap_text = SUB_LOOP_CAP_MARKER +
"no assistant reply"
504 usage=merged_usage
or None,
505 iterations=max_iterations,
506 tool_calls=total_tool_calls,
508 unknown_usage_raw=unknown_usage_raw,
518 final_text = (assistant_msg.get(
"content")
or "").strip()
522 _emit(make_subagent_completed, outcome=
"ok")
525 artifact_path: str |
None =
None
526 if session_name
is not None:
531 "iterations": iteration + 1,
532 "max_iterations": max_iterations,
533 "final_length": len(final_text),
535 "messages": log_archive,
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)
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,
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)