1289 session:
"ChatSession",
1290 client:
"ClientInterface",
1291 presenter:
"Presenter",
1292 model_manager:
"ModelManager",
1293 tool_registry: dict,
1295 ctx:
"Context | None" =
None,
1296 turn_stats:
"TurnStats | None" =
None,
1297 enhanced_presenter:
"EnhancedPresenter | None" =
None,
1298 cancellation:
"CancellationToken | None" =
None,
1300 """Execute one user-turn through up to ``MAX_REACT_ITERATIONS`` ReAct cycles.
1302 Multiple tool calls in a single model response are executed in
1303 parallel. Captures usage data and records it on the session.
1304 Shows per-iteration usage and a final aggregated turn summary.
1306 If *ctx* is provided and ``ctx.in_goal`` is ``True``, file-modifying
1307 tool calls (``write_file``) are recorded in ``ctx.goal_files_changed``
1308 for later changelog updates.
1310 If *turn_stats* is provided (a :class:`TurnStats`), its counters are
1311 updated so the prompt's rprompt can show live token counts.
1317 _turn_t0 = time.monotonic()
1321 stall_detector = StallDetector.from_defaults()
1330 turn_id = new_event_id()
1331 sid = session.session_id
1334 return {
"event_id": new_event_id(),
"parent_event_id": turn_id,
1335 "session_id": sid,
"ts":
_now_iso(),
"turn_id": turn_id}
1337 default_bus().publish(make_user_input_received(
1343 _ep_registry = getattr(ctx,
"endpoint_registry",
None)
if ctx
is not None else None
1344 if _ep_registry
is not None and _ep_registry.entries:
1346 client.rebind_to(_ep_registry.active())
1352 if turn_stats
is not None:
1353 turn_stats.start_turn()
1355 react_cap = session.react_cap()
1356 _dbg(
"OC_CHAT.MAIN", func,
"turn START", 2)
1363 bind_active_session(session)
1365 presenter.start_turn()
1367 ensure_system_includes_tool_reference(session, tool_schemas)
1376 _vcfg = verification_config(runtime_settings(), runtime_mode())
1377 _verif_enabled = _vcfg.enabled
1378 _verif_baseline =
None
1379 if _verif_enabled
and _vcfg.baseline:
1380 _verif_baseline = getattr(session,
"_verification_baseline",
None)
1381 if _verif_baseline
is None:
1383 _bspin = presenter.spinner_start(
"capturing verification baseline…")
1385 _verif_baseline = capture_baseline(_vcfg)
1387 presenter.spinner_stop(_bspin)
1389 session._verification_baseline = _verif_baseline
1390 except (AttributeError, TypeError):
1393 sub_loops_this_turn = 0
1399 def _confirm_stop() -> bool:
1408 if not _sys.stdin.isatty():
1410 return read_single_key(
"\nStop the current action? (y/N) ", valid=
"yn") ==
"y"
1412 for _iteration
in range(react_cap):
1416 confirm_or_continue(cancellation, _confirm_stop)
1423 _precheck =
_cost_gate(session, presenter, acc, _turn_t0)
1428 t0 = time.monotonic()
1430 route = resolve_iteration_routing(
1431 session_model=session.model,
1432 session_reasoning_effort=session.reasoning_effort,
1433 model_manager=model_manager,
1434 iteration=_iteration,
1435 is_finalize_nudge=(_iteration == react_cap - 1),
1436 messages=session.messages,
1437 token_profile=getattr(session,
"token_profile",
"balanced")
or "balanced",
1440 _dbg(
"OC_CHAT.MAIN", func,
1441 f
"iter {_iteration}/{react_cap} model={route.model} effort={route.effort} max_tokens={route.max_tokens}", 3)
1443 caps = model_manager.capabilities(route.model)
1444 tools = tool_schemas
if (tool_registry
and caps.get(
"supports_tools",
True))
else None
1446 tool_calls_this_iter: list = []
1452 finish_reason: str |
None =
None
1454 api_messages = messages_for_api(
1456 session_name=session.name,
1457 inject_finalize_nudge=(_iteration == react_cap - 1),
1463 should_use_sub = should_use_sub_loop(
1465 iteration_count=_iteration,
1466 recent_messages=session.messages[-10:]
1487 sub_model = session.model
1488 wants_sub_loop = should_use_sub
or is_repeating
1489 if wants_sub_loop
and sub_loops_this_turn >= _MAX_SUB_LOOPS_PER_TURN:
1490 presenter.show_info(
1491 f
"[sub-loop] iter={_iteration}: offload suppressed "
1492 f
"(per-turn cap {_MAX_SUB_LOOPS_PER_TURN} reached)"
1494 _dbg(
"OC_CHAT.MAIN", func,
1495 f
"sub-loop suppressed at iter {_iteration}: per-turn cap reached", 2)
1496 if wants_sub_loop
and sub_loops_this_turn < _MAX_SUB_LOOPS_PER_TURN:
1497 sub_loops_this_turn += 1
1498 presenter.show_info(
1499 f
"[sub-loop] iter={_iteration}: running sub-loop on {sub_model} "
1500 f
"(complexity={should_use_sub} repetition={is_repeating})"
1502 _dbg(
"OC_CHAT.MAIN", func,
1503 f
"triggering sub-loop on {sub_model} complexity_task={should_use_sub} repetition={is_repeating}", 2)
1507 sub_task =
"Complete the assigned task using available tools."
1512 sub_task +=
"\n\n[CONTEXT_REFRESH: The previous responses were too similar. Please provide a different perspective or approach.]"
1515 if enhanced_presenter
is not None:
1516 enhanced_presenter.show_sub_loop_trigger(sub_model, sub_task)
1519 if hasattr(enhanced_presenter,
'get_stats_tracker'):
1520 stats_tracker = enhanced_presenter.get_stats_tracker()
1523 api_prompt = last_usage.get(
"prompt_tokens", 0)
if last_usage
else 0
1525 stats_tracker.update_context_usage(api_prompt)
1527 estimated_tokens = _estimate_tokens(session.messages)
1528 stats_tracker.update_context_usage(estimated_tokens)
1534 prompt_tokens = last_usage.get(
'prompt_tokens', 0)
if last_usage
else 0
1535 completion_tokens = last_usage.get(
'completion_tokens', 0)
if last_usage
else 0
1536 cache_hits = last_usage.get(
'cache_hit_tokens', 0)
if last_usage
else 0
1537 tool_calls = len(tool_calls_this_iter)
if tool_calls_this_iter
else 0
1539 stats_tracker.update_turn_stats(
1540 prompt_tokens=prompt_tokens,
1541 completion_tokens=completion_tokens,
1542 cache_hits=cache_hits,
1543 tool_calls=tool_calls
1553 tool_registry, tool_schemas
or [])
1554 sub_result = run_sub_loop(
1558 tool_registry=_sub_registry,
1559 tool_schemas=_sub_schemas,
1560 session_name=session.name,
1561 presenter=presenter,
1562 parent_event_id=turn_id, parent_session_id=sid,
1574 sub_text = redact_secrets(sub_result.text)
1575 _sub_cap = resolve_tool_result_cap(session.model)
1576 if len(sub_text) > _sub_cap:
1577 if sub_result.artifact_path:
1579 f
"\n[OUTPUT_TRUNCATED at {_sub_cap} chars; full sub-loop "
1580 f
"transcript archived at {sub_result.artifact_path}]"
1583 _marker = f
"\n[OUTPUT_TRUNCATED at {_sub_cap} chars]"
1584 sub_text = sub_text[:_sub_cap] + _marker
1585 session.messages.append({
1587 "content": f
"[Sub-loop result]\n{sub_text}",
1591 if hasattr(presenter,
'get_stats_tracker'):
1592 stats_tracker = presenter.get_stats_tracker()
1595 api_prompt = last_usage.get(
"prompt_tokens", 0)
if last_usage
else 0
1597 stats_tracker.update_context_usage(api_prompt)
1599 estimated_tokens = _estimate_tokens(session.messages)
1600 stats_tracker.update_context_usage(estimated_tokens)
1606 prompt_tokens = last_usage.get(
'prompt_tokens', 0)
if last_usage
else 0
1607 completion_tokens = last_usage.get(
'completion_tokens', 0)
if last_usage
else 0
1608 cache_hits = last_usage.get(
'cache_hit_tokens', 0)
if last_usage
else 0
1609 tool_calls = len(tool_calls_this_iter)
if tool_calls_this_iter
else 0
1611 stats_tracker.update_turn_stats(
1612 prompt_tokens=prompt_tokens,
1613 completion_tokens=completion_tokens,
1614 cache_hits=cache_hits,
1615 tool_calls=tool_calls
1626 sub_model=sub_model, at=at_sub)
1631 if sub_model
and sub_result.usage:
1632 _sub_cache = client.parse_usage(sub_result.usage)
1635 cache_hit_tokens=_sub_cache.cache_hit_tokens,
1636 cache_miss_tokens=_sub_cache.cache_miss_tokens,
1637 prompt_tokens=int(sub_result.usage.get(
"prompt_tokens", 0)
or 0),
1638 completion_tokens=int(sub_result.usage.get(
"completion_tokens", 0)
or 0),
1639 at=at_sub, session=session,
1645 _sub_verdict =
_cost_gate(session, presenter, acc, _turn_t0)
1646 if _sub_verdict.stop:
1651 sub_result.unknown_usage_raw)
1652 _dbg(
"OC_CHAT.MAIN", func,
1653 f
"sub-loop returned {len(sub_result.text)} chars "
1654 f
"(iters={sub_result.iterations} tools={sub_result.tool_calls})", 2)
1658 _extra_body = route.extra_body
1659 if ctx
is not None and ctx.generation_options
is not None:
1660 _gen_opts = client.provider.translate_generation_options(ctx.generation_options)
1662 _extra_body = {**(_extra_body
or {}), **_gen_opts}
1670 default_bus().publish(make_prompt_rendered(
1673 layer_ids=_layer_ids, layer_hashes=_layer_hashes,
1685 with presenter.turn():
1686 presenter.show_waiting()
1687 for chunk_type, data
in client.stream_chat(
1690 effort=route.effort,
1691 extra_body=_extra_body,
1693 max_tokens=route.max_tokens,
1694 cancellation=cancellation,
1696 if chunk_type ==
"reasoning":
1697 full_reasoning += data
1698 presenter.show_reasoning(data)
1699 elif chunk_type ==
"content":
1700 full_content += data
1701 presenter.show_content(data)
1702 elif chunk_type ==
"tool_call":
1703 tool_calls_this_iter.append(data)
1704 elif chunk_type ==
"usage":
1706 elif chunk_type ==
"finish_reason":
1707 finish_reason = data
1708 except (KeyboardInterrupt, CancellationError):
1712 _dbg(
"OC_CHAT.MAIN", func,
1713 "stream cancelled by user (Ctrl+C)", 2, override=
True)
1715 if last_usage
is not None:
1717 partial_usage = last_usage
1718 cost_label =
"exact"
1723 est_prompt = count_message_tokens(api_messages)
1725 count_tokens(full_content) + count_tokens(full_reasoning)
1728 "prompt_tokens": est_prompt,
1729 "completion_tokens": est_completion,
1730 "total_tokens": est_prompt + est_completion,
1733 cost_label =
"approx"
1735 session.record_usage(partial_usage)
1736 if turn_stats
is not None:
1737 turn_stats.add_usage(partial_usage)
1741 pt = int(partial_usage.get(
"prompt_tokens")
or 0)
1742 session.last_turn_max_prompt_tokens = max(
1743 int(getattr(session,
"last_turn_max_prompt_tokens", 0)
or 0), pt
1745 acc.prompt_tokens += int(partial_usage.get(
"prompt_tokens", 0))
1746 acc.completion_tokens += int(partial_usage.get(
"completion_tokens", 0))
1747 acc.total_tokens += int(partial_usage.get(
"total_tokens", 0))
1748 acc.max_prompt_in_turn = max(acc.max_prompt_in_turn, pt)
1750 p = partial_usage.get(
"prompt_tokens", 0)
1751 c = partial_usage.get(
"completion_tokens", 0)
1752 presenter.show_info(
1753 f
"Cancelled. ({cost_label} cost: {p} prompt + {c} completion tokens)"
1757 default_bus().publish(make_error(
1758 **_ev(), error_summary=
"cancelled: stream cancelled by user"))
1761 except Exception
as e:
1763 _dbg(
"OC_CHAT.MAIN", func, f
"SYSTEM_ERROR: {type(e).__name__}: {e}", 1, override=
True)
1765 default_bus().publish(make_error(
1766 **_ev(), error_summary=f
"{type(e).__name__}: {e}"))
1778 if finish_reason ==
"length":
1779 _dbg(
"OC_CHAT.MAIN", func,
1780 f
"TRUNCATED at max_tokens: iter={_iteration} model={route.model} "
1781 f
"effort={route.effort} max_tokens={route.max_tokens} "
1782 f
"content={len(full_content)}c reasoning={len(full_reasoning)}c "
1783 f
"tool_calls={len(tool_calls_this_iter)}", 1, override=
True)
1784 if tool_calls_this_iter:
1785 presenter.show_info(TRUNCATED_TOOL_CALL_NOTICE.format(
1786 cap=route.max_tokens, n=len(tool_calls_this_iter)))
1791 presenter.show_usage(last_usage)
1792 session.record_usage(last_usage)
1794 acc.prompt_tokens += last_usage.get(
"prompt_tokens", 0)
1795 acc.completion_tokens += last_usage.get(
"completion_tokens", 0)
1796 acc.total_tokens += last_usage.get(
"total_tokens", 0)
1797 if last_usage.get(
"prompt_tokens_details"):
1798 pd = last_usage[
"prompt_tokens_details"]
1799 if isinstance(pd, dict):
1800 acc.prompt_cached += pd.get(
"cached_tokens", 0)
1804 usage_stats = client.parse_usage(last_usage)
1805 perf = client.parse_performance(last_usage)
1806 if not perf.is_default():
1807 acc.perf_stats = perf
1808 _dbg(
"OC_CHAT.CLIENT", func,
1809 f
"usage: prompt={last_usage.get('prompt_tokens', 0)} "
1810 f
"completion={last_usage.get('completion_tokens', 0)} "
1811 f
"cache_hit={usage_stats.cache_hit_tokens} "
1812 f
"cache_miss={usage_stats.cache_miss_tokens}", 3)
1813 acc.cache_hit_tokens += usage_stats.cache_hit_tokens
1814 acc.cache_miss_tokens += usage_stats.cache_miss_tokens
1820 cache_hit_tokens=usage_stats.cache_hit_tokens,
1821 cache_miss_tokens=usage_stats.cache_miss_tokens,
1822 prompt_tokens=int(last_usage.get(
"prompt_tokens")
or 0),
1823 completion_tokens=int(last_usage.get(
"completion_tokens")
or 0),
1830 cache_hit_tokens=usage_stats.cache_hit_tokens,
1831 cache_miss_tokens=usage_stats.cache_miss_tokens,
1832 prompt_tokens=int(last_usage.get(
"prompt_tokens")
or 0),
1833 completion_tokens=int(last_usage.get(
"completion_tokens")
or 0),
1834 at=at_sample, session=session,
1840 _verdict =
_cost_gate(session, presenter, acc, _turn_t0)
1845 pt = int(last_usage.get(
"prompt_tokens")
or 0)
1846 acc.max_prompt_in_turn = max(acc.max_prompt_in_turn, pt)
1849 if turn_stats
is not None:
1850 turn_stats.add_usage(last_usage)
1854 dur_ms = int((time.monotonic() - t0) * 1000)
1855 _u = last_usage
or {}
1856 _cache_hit = usage_stats.cache_hit_tokens
if usage_stats
else 0
1857 _cache_miss = usage_stats.cache_miss_tokens
if usage_stats
else 0
1858 _now = datetime.now().astimezone()
1859 _ts = _now.isoformat(timespec=
'milliseconds')
1864 effort=route.effort
or "",
1865 tools=tuple(tc[
"name"]
for tc
in tool_calls_this_iter),
1866 prompt_tokens=_u.get(
"prompt_tokens", 0),
1867 completion_tokens=_u.get(
"completion_tokens", 0),
1868 cache_hit_tokens=_cache_hit,
1869 cache_miss_tokens=_cache_miss,
1872 if getattr(session,
'show_iteration_stats',
True):
1873 presenter.show_iteration_stat(stat)
1874 if session.iteration_stats_file
is not None:
1876 _tools_csv =
",".join(stat.tools)
if stat.tools
else "-"
1877 session.iteration_stats_file.write(
1878 f
'{stat.ts},{stat.iter},{stat.model},{stat.effort},{_tools_csv},'
1879 f
'{stat.prompt_tokens},{stat.completion_tokens},'
1880 f
'{stat.cache_hit_tokens},{stat.cache_miss_tokens},{stat.dur_ms}\n'
1882 session.iteration_stats_file.flush()
1886 if tool_calls_this_iter:
1887 _dbg(
"OC_CHAT.TOOLS", func,
1888 f
"dispatching {len(tool_calls_this_iter)} tool(s): "
1889 f
"{[tc['name'] for tc in tool_calls_this_iter]}", 2)
1894 partition_call_budget,
1895 resolve_max_tool_calls_per_turn,
1897 _tool_cap = resolve_max_tool_calls_per_turn()
1904 _effective_cap = _tool_cap * (1 + acc.cap_extensions)
1905 if acc.tool_calls + len(tool_calls_this_iter) > _effective_cap:
1907 if tool_call_checkpoint(
1908 presenter=presenter,
1910 effective_cap=_effective_cap,
1912 session_id=session.session_id,
1914 acc.cap_extensions += 1
1915 _effective_cap = _tool_cap * (1 + acc.cap_extensions)
1916 _dbg(
"OC_CHAT.TOOLS", func,
1917 f
"per-turn tool-call budget extended to {_effective_cap}", 2)
1918 _allowed_calls, _denied_calls = partition_call_budget(
1919 acc.tool_calls, len(tool_calls_this_iter), cap=_effective_cap)
1923 _stall_denied_indices: set[int] = set()
1925 for _stall_idx, _stall_tc
in enumerate(tool_calls_this_iter):
1926 if _stall_idx >= _allowed_calls:
1928 _stall = stall_detector.check_proposal(
1931 if _stall
is not None:
1932 _stall_denied_indices.update(
1933 range(_stall_idx, _allowed_calls),
1938 stall_sentence(_stall),
1942 _dbg(
"OC_CHAT.TOOLS", func,
1943 f
"per-turn tool-call cap: {_denied_calls} call(s) denied "
1944 f
"(budget {_effective_cap}/turn)", 1, override=
True)
1945 acc.tool_calls += len(tool_calls_this_iter)
1950 for _tc_named
in tool_calls_this_iter:
1951 _name = _tc_named.get(
"name",
"?")
1952 acc.tool_name_counts[_name] = acc.tool_name_counts.get(_name, 0) + 1
1959 _cap_warn_at = max(1, (_tool_cap * 4) // 5)
1960 if (
not acc.cap_warning_emitted
1961 and acc.tool_calls >= _cap_warn_at
1962 and acc.tool_calls < _tool_cap):
1963 acc.cap_warning_emitted =
True
1964 _calls_left = _tool_cap - acc.tool_calls
1965 session.messages.append({
1968 f
"[system] Approaching the per-turn tool-call limit: "
1969 f
"{acc.tool_calls}/{_tool_cap} calls used this turn, {_calls_left} "
1970 f
"remain. Checkpoint your progress and make only the essential "
1971 f
"remaining calls; work past the limit is deferred to a new turn."
1974 session.dirty =
True
1975 _dbg(
"OC_CHAT.TOOLS", func,
1976 f
"cap-approach warning injected at {acc.tool_calls}/{_tool_cap}", 2)
1981 assistant_msg: dict = {
1982 "role":
"assistant",
1983 "content": full_content
or None,
1988 "function": {
"name": tc[
"name"],
"arguments": tc[
"arguments"]},
1990 for tc
in tool_calls_this_iter
1994 assistant_msg[
"reasoning_content"] = full_reasoning
1995 session.add_assistant_with_tool_calls(assistant_msg)
1998 for _tc
in tool_calls_this_iter:
1999 default_bus().publish(make_tool_call_proposed(
2006 tool_calls_this_iter,
2009 stall_denied=_stall_denied_indices,
2018 for tc
in tool_calls_this_iter:
2019 presenter.show_tool_call(tc[
"name"], tc[
"arguments"])
2026 for _idx, _tc
in enumerate(tool_calls_this_iter):
2027 if _idx >= _allowed_calls
or _idx
in _stall_denied_indices:
2029 default_bus().publish(link_started_to_decision(
2030 make_tool_call_started(
2031 **_ev(), tool_call_id=_tc[
"id"], tool_name=_tc[
"name"]),
2032 _decision_ids[_idx]))
2040 def _dispatch_or_cap(tc: dict, idx: int) ->
"tuple[dict, dict]":
2042 if idx >= _allowed_calls:
2043 return tc, {
"error": (
2044 f
"Per-turn tool-call cap reached ({_effective_cap} "
2045 f
"calls/turn); this call was not executed. Continue in a new turn."
2047 if idx
in _stall_denied_indices:
2048 return tc, {
"error": _stall_hit[2]}
2057 if len(tool_calls_this_iter) > 1:
2058 _spinner_label = f
"running {len(tool_calls_this_iter)} tools…"
2060 def _dispatch_all() -> "list[tuple[dict, dict]]":
2061 with ThreadPoolExecutor(max_workers=clamp_workers(len(tool_calls_this_iter)))
as executor:
2062 futures = [executor.submit(_dispatch_or_cap, tc, i)
for i, tc
in enumerate(tool_calls_this_iter)]
2063 return [f.result()
for f
in futures]
2065 ordered =
_run_bracketed(presenter, _spinner_label, _dispatch_all)
2067 _spinner_label = f
"running {tool_calls_this_iter[0]['name']}…"
2069 presenter, _spinner_label,
2070 lambda: [_dispatch_or_cap(tool_calls_this_iter[0], 0)])
2072 _observed_indices = [
2073 idx
for idx
in range(len(ordered))
2074 if idx < _allowed_calls
and idx
not in _stall_denied_indices
2076 _last_observed_idx = (
2077 _observed_indices[-1]
if _observed_indices
else None
2079 for _result_idx, (tc, result)
in enumerate(ordered):
2084 if isinstance(result, dict)
and result.get(
"requires_confirmation"):
2085 _gate = result.get(
"gate")
2086 if _gate ==
"external_content":
2088 elif _gate ==
"protected_write":
2098 _pd_blocked = isinstance(result, dict)
and (
2099 result.get(
"requires_confirmation")
or result.get(
"error")
is not None)
2111 default_bus().publish(make_permission_decision(
2113 permission_decision_id=new_event_id(),
2114 decision=
"deny" if _pd_blocked
else "allow",
2116 authorization_mode=(
2117 "human_confirm" if (
not _pd_blocked
and _pd_by ==
"human")
else None),
2119 _err = result.get(
"error")
if isinstance(result, dict)
else None
2123 _rc = result.get(
"returncode")
if isinstance(result, dict)
else None
2124 _failed = bool(_err)
or (isinstance(_rc, int)
and _rc != 0)
2125 _status = (
"ERROR: " + _err)
if _err
else (f
"rc={_rc}" if _failed
else "OK")
2126 _dbg(
"OC_CHAT.TOOLS", func, f
"tool={tc['name']} {_status}", 1
if _failed
else 3)
2131 default_bus().publish(make_tool_call_completed(
2132 **_ev(), tool_call_id=tc[
"id"], outcome=
"error" if _failed
else "ok",
2133 error_summary=_error_summary))
2134 if _result_idx
in _observed_indices:
2135 _capability = tool_capability(tc[
"name"])
2136 stall_detector.observe(
2139 outcome=
"error" if _failed
else "ok",
2140 error_summary=_error_summary,
2141 is_write=_capability
not in {
"read",
"list"},
2142 iteration=_iteration,
2143 iteration_complete=_result_idx == _last_observed_idx,
2145 presenter.show_tool_result(tc[
"name"], result)
2148 if ctx
is not None and ctx.in_goal
and tc[
"name"] ==
"write_file":
2150 args = json.loads(tc[
"arguments"]
or "{}")
2151 path = args.get(
"path",
"")
2152 if path
and path
not in ctx.goal_files_changed:
2153 ctx.goal_files_changed.append(path)
2154 except (json.JSONDecodeError, KeyError):
2158 if ctx
is not None and ctx.in_plan
and tc[
"name"] ==
"write_file":
2160 args = json.loads(tc[
"arguments"]
or "{}")
2161 path = args.get(
"path",
"")
2162 if path
and path
not in ctx.plan_files_changed:
2163 ctx.plan_files_changed.append(path)
2164 except (json.JSONDecodeError, KeyError):
2172 if not _failed
and tc[
"name"] ==
"write_file":
2174 if _written
and _written
not in acc.files_written:
2175 acc.files_written.append(_written)
2180 if _verif_enabled
and tc[
"name"]
in _vcfg.mutating_tools:
2182 for _p
in changed_paths_from_result(tc[
"name"],
_safe_tool_args(tc), result):
2183 acc.changed_files.add(_p)
2187 "tool_call_id": tc[
"id"],
2188 "content": json.dumps(result, ensure_ascii=
False),
2190 session.add_tool_result(tool_result_msg)
2192 if _stall_hit
is not None:
2194 _stall_tc, _stall, _stall_reason = _stall_hit
2195 if stall_checkpoint(
2196 presenter=presenter,
2198 sentence=_stall_reason,
2200 session_id=session.session_id,
2202 stall_detector.reset_shape(
2206 default_bus().publish(make_error(
2207 **_ev(), error_summary=_stall_reason,
2209 presenter.show_error(_stall_reason)
2218 if _verif_enabled
and acc.changed_files:
2223 _dbg(
"OC_CHAT.MAIN", func,
"final answer received", 2)
2226 "role":
"assistant",
2227 "content": full_content,
2230 msg_entry[
"reasoning_content"] = full_reasoning
2231 session.messages.append(msg_entry)
2232 session.dirty =
True
2233 session.stats.add_message()
2235 default_bus().publish(make_assistant_message(
2236 **_ev(), text=full_content, is_final=
True))
2240 if finish_reason ==
"length":
2241 presenter.show_info(
2242 TRUNCATED_ANSWER_NOTICE.format(cap=route.max_tokens))
2243 elif full_reasoning:
2254 "role":
"assistant",
2255 "content": REASONING_ONLY_CONTENT,
2256 "reasoning_content": full_reasoning,
2258 session.messages.append(msg_entry)
2259 session.dirty =
True
2260 session.stats.add_message()
2262 default_bus().publish(make_assistant_message(
2263 **_ev(), text=REASONING_ONLY_CONTENT, is_final=
True))
2264 _dbg(
"OC_CHAT.MAIN", func,
2265 f
"reasoning-only completion: {len(full_reasoning)} chars kept, "
2266 f
"no content token received (finish_reason={finish_reason})",
2268 _cause = (CAUSE_LENGTH.format(cap=route.max_tokens)
2269 if finish_reason ==
"length" else CAUSE_UNKNOWN)
2270 presenter.show_info(REASONING_ONLY_NOTICE.format(
2271 n=len(full_reasoning), cause=_cause))
2273 if session.messages
and session.messages[-1][
"role"] ==
"user":
2274 session.messages.pop()
2276 default_bus().publish(make_error(
2277 **_ev(), error_summary=
"empty_completion: no response content received"))
2278 presenter.show_info(
"[No response content received]")
2281 _dbg(
"OC_CHAT.MAIN", func,
2282 f
"hit ReAct cap ({react_cap} iterations) without final answer", 1, override=
True)
2287 f
"[Reached the maximum of {react_cap} ReAct iterations without a final reply — "
2288 "try narrowing the task, /session clear, /session new, or starting a new session.]"
2290 if _verif_enabled
and acc.pending_findings
is not None:
2292 " Note: verification found unreconciled issues in the files changed "
2293 "this turn — review them before continuing."
2295 presenter.show_info(_cap_msg)
2297 session.last_turn_max_prompt_tokens = acc.max_prompt_in_turn
2299 n_user_msgs = sum(1
for m
in session.messages
if m.get(
"role") ==
"user")
2300 if n_user_msgs >= 12
and not session.rollover_hint_shown:
2301 session.rollover_hint_shown =
True
2302 presenter.show_info(
2303 "[Tip] This conversation has many user turns. For a shorter prefix and better cache "
2304 "efficiency, consider `/session save` then `/session new` (or export) and continue in a fresh session."
2307 presenter.end_turn()
2309 _dbg(
"OC_CHAT.MAIN", func,
2310 f
"turn END iters={acc.iterations} tools={acc.tool_calls} "
2311 f
"prompt={acc.prompt_tokens} completion={acc.completion_tokens} "
2312 f
"cached={acc.prompt_cached}", 2)
2313 if acc.iterations > 0:
2314 pc = acc.prompt_cached
if acc.prompt_cached > 0
else None
2315 has_cache = acc.cache_hit_tokens + acc.cache_miss_tokens > 0
2316 pct = cache_hit_ratio_pct(acc.cache_hit_tokens, acc.cache_miss_tokens)
if has_cache
else None
2317 saved = acc.cache_hit_tokens
if has_cache
else None
2322 ctx_window = get_max_context_tokens(session.model)
2323 context_used_pct =
None
2324 if ctx_window > 0
and acc.max_prompt_in_turn > 0:
2325 context_used_pct = max(0.0, min(100.0,
2326 acc.max_prompt_in_turn / ctx_window * 100.0))
2327 presenter.show_turn_summary(
2328 prompt=acc.prompt_tokens,
2329 completion=acc.completion_tokens,
2330 total=acc.total_tokens,
2331 iterations=acc.iterations,
2332 tool_calls=acc.tool_calls,
2335 cache_saved_tokens=saved,
2336 performance_stats=acc.perf_stats,
2337 model=session.model,
2338 context_used_pct=context_used_pct,
2339 duration_ms=int((time.monotonic() - _turn_t0) * 1000),
2342 turn_cost=acc.cost.result(),
2344 if acc.cache_hit_tokens + acc.cache_miss_tokens > 0:
2346 "hit": acc.cache_hit_tokens,
2347 "miss": acc.cache_miss_tokens,
2348 "prompt": acc.prompt_tokens,
2350 session.cache_turn_snapshots.append(snap)
2351 session.cache_turn_snapshots = session.cache_turn_snapshots[-30:]
2352 session.dirty =
True
2355 if turn_stats
is not None:
2356 session_cache_pct = cache_hit_ratio_pct(
2357 session.stats.prompt_cache_hit_tokens,
2358 session.stats.prompt_cache_miss_tokens,
2360 turn_stats.finish_turn(
2361 session.stats.total_tokens,
2362 iterations=acc.iterations,
2363 tool_calls=acc.tool_calls,
2364 session_cache_pct=session_cache_pct,
2365 session_cache_saved=session.stats.prompt_cache_hit_tokens,
2373 save_chat_log(session)
2374 save_session_metadata(session)
2375 except RuntimeError
as exc:
2378 presenter.show_info(f
"[auto-save: {exc}]")
2380 save_session_metadata(session)
2381 except Exception
as exc2:
2382 presenter.show_error(f
"Auto-save metadata failed: {exc2}")
2383 except Exception
as exc:
2384 presenter.show_error(f
"Auto-save failed: {exc}")