Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
25505e9
cli-67: retry on empty addition
Sep 29, 2026
f82edcb
cli-67: retry on empty addition
Sep 29, 2026
e3107a0
fixed linting
Sep 29, 2026
3bc0b51
Merge branch 'v1.7.1' into cli-67-retry-on-empty-addition
szmania Oct 8, 2026
0a7e0f2
fix: Resolve Bedrock endpoint from resolved api_base
Oct 12, 2026
ec4f38c
feat: add test to ensure api_base can override bedrock base url
Oct 12, 2026
7d37fd4
Allow local directory for workflow definitions
dwash96 Oct 13, 2026
a21aac3
Fix scheduler configuration documentation
dwash96 Oct 13, 2026
5ec5c97
Merge pull request #702 from szmania/cli-67-retry-on-empty-addition
dwash96 Oct 13, 2026
6ddf1a0
Merge pull request #712 from laurensmiers/fix-bedrock-endpoint-override
dwash96 Oct 13, 2026
15af71f
Fix formatting
dwash96 Oct 13, 2026
5adde85
Also support .agents/workflows
dwash96 Oct 14, 2026
5d58fff
More accurate token estimator
dwash96 Oct 14, 2026
9c5114f
Prevent full file context from causing infinite compaction churn
dwash96 Oct 14, 2026
10fbff5
Fix order of coder root assignment
dwash96 Oct 16, 2026
0b02908
Make scheduler a normally parsed nested config structure instead of a…
dwash96 Oct 16, 2026
f3cd9b2
Prevent scheduler cross thread coder call which locks TUI
dwash96 Oct 16, 2026
137fd1c
Synchronous Threading.lock instead of asyncio.Lock to prevent interna…
dwash96 Oct 16, 2026
00cfb89
Fix formatting
dwash96 Oct 16, 2026
eeaee32
Prevent over zealous compaction on unknown model max input tokens
dwash96 Oct 16, 2026
b07a9f6
Windows coarse clocks part 3
dwash96 Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,13 +440,9 @@ def get_parser(default_config_files, git_root):
)
group.add_argument(
"--scheduler",
action=argparse.BooleanOptionalAction,
metavar="SCHEDULER_CONFIG_JSON",
help="Specify scheduler configuration as a JSON string",
default=None,
help=(
"Force the workflow scheduler on (--scheduler) or off (--no-scheduler) for"
" this session. (default: auto, on when any enabled scheduled workflow"
" exists)"
),
)
group.add_argument(
"--system-one",
Expand Down
55 changes: 50 additions & 5 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2253,6 +2253,31 @@ async def compact_context_if_needed(self, force=False, message=""):
)
return

# Compaction can only shrink the conversation buckets counted above.
# If the fixed context (files added to the chat, repo map, system
# prompt, rules, tool schemas) already meets the limit on its own,
# summarizing cannot bring the context under the limit. Bail out with
# guidance instead of churning every turn, which is what a large file
# added to the chat otherwise causes.
non_compactable_tokens = all_tokens - compactable_tokens

if (
self.context_compaction_max_tokens
and non_compactable_tokens >= self.context_compaction_max_tokens
):
if getattr(self, "_compaction_noop_warning_time", 0) + 300 < time.time():
self._compaction_noop_warning_time = time.time()
self.io.tool_warning(
"Compaction cannot reduce the context: the fixed context (files in the"
f" chat, repo map, prompts) is already ~{non_compactable_tokens:,}"
f" tokens, at or above the ~{self.context_compaction_max_tokens:,} token"
" compaction limit."
)
self.io.tool_output("- Use /drop to remove unneeded files from the chat")
self.io.tool_output("- Use /clear to clear the chat history")

return

message_tokens = done_tokens + cur_tokens
file_tokens = diff_tokens + file_context_tokens
combined_tokens = done_tokens + cur_tokens + diff_tokens + file_context_tokens
Expand Down Expand Up @@ -2692,7 +2717,7 @@ def warm_cache_worker():
async def check_tokens(self, messages):
"""Check if the messages will fit within the model's token limits."""
input_tokens = self.get_active_model().token_count(messages)
max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 0
max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 128000

if max_input_tokens and input_tokens >= max_input_tokens:
if (
Expand All @@ -2703,7 +2728,7 @@ async def check_tokens(self, messages):
f"Estimated chat context of {input_tokens:,} tokens exceeds the"
f" {max_input_tokens:,} token limit. Attempting to compact..."
)
await self.compact_context_if_needed(force=True)
await self.compact_context_if_needed()

# After compaction, re-format messages and re-check tokens
messages = self.format_messages()
Expand Down Expand Up @@ -3996,7 +4021,7 @@ async def show_send_output(self, completion):
if (
not len(self.partial_response_content)
and not len(self.partial_response_tool_calls)
and not len(self.partial_response_reasoning_content)
and not _is_meaningful_reasoning(self.partial_response_reasoning_content)
):
self.empty_response = True
return
Expand Down Expand Up @@ -4126,7 +4151,8 @@ async def show_send_output_stream(self, completion):

text += reasoning_content
self.got_reasoning_content = True
received_content = True
if _is_meaningful_reasoning(reasoning_content):
received_content = True

self.token_profiler.on_token()
self.io.update_spinner_suffix(reasoning_content)
Expand Down Expand Up @@ -4202,7 +4228,14 @@ async def show_send_output_stream(self, completion):
self.io.tool_warning("Execution stopped by on message hook")
return

if not received_content and len(self.partial_response_tool_calls) == 0:
# Treat the response as empty when nothing was received, or when the
# only thing received was reasoning made entirely of non-alphanumeric
# characters (e.g. moonshotai/kimi-k3 returning "!!!!").
if (
not received_content
and len(self.partial_response_tool_calls) == 0
and not _is_meaningful_reasoning(self.partial_response_reasoning_content)
):
self.empty_response = True
return

Expand Down Expand Up @@ -5391,3 +5424,15 @@ def _first_usage_tokens(usage: object, paths: list[str], default: int = 0) -> in
if value is not None:
return value
return default


def _is_meaningful_reasoning(text):
"""Return True if reasoning text contains at least one alphanumeric character.

Some providers (e.g. moonshotai/kimi-k3) occasionally return completions
with empty ``content`` and a ``reasoning_content`` made entirely of
punctuation (e.g. ``"!!!!"``). Those responses are effectively empty, so
the empty-response detector only lets reasoning count as response
content when it holds at least one alphanumeric character.
"""
return bool(text) and any(ch.isalnum() for ch in text)
45 changes: 34 additions & 11 deletions cecli/helpers/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import asyncio
import logging
import threading
import time
import weakref
from dataclasses import dataclass
Expand Down Expand Up @@ -80,8 +81,22 @@ class AgentService:
_uuid_coder_map: Dict[str, weakref.ref] = {}
# Lock pools keyed by parent UUID — created lazily so only parents that
# actually use them allocate a lock.
_spawn_locks: Dict[str, asyncio.Lock] = {}
_conversation_locks: Dict[str, asyncio.Lock] = {}
#
# Plain ``threading.Lock`` (not ``asyncio.Lock``): an ``asyncio.Lock`` binds
# itself to the event loop of its first *waiting* acquirer and raises
# ``RuntimeError('... is bound to a different event loop')`` when awaited
# from another loop. Spawn, reap and conversation writes all reach these
# pools from both the TUI main (Textual) loop and the coder worker-thread
# loop, and the pools are class-level, so a lock created before a hot reload
# outlives the loop it bound to. Every guarded section is fully synchronous,
# so a thread lock gives true cross-loop mutual exclusion with no loop
# affinity and is immune to task cancellation.
#
# Invariant: never hold one of these locks across an ``await`` — the guarded
# sections must stay synchronous, or the blocking acquire could stall the
# event loop.
_spawn_locks: Dict[str, threading.Lock] = {}
_conversation_locks: Dict[str, threading.Lock] = {}
_primary_agent_uuid: str = None

# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -305,10 +320,15 @@ def max_sub_agents(self) -> int:
# ------------------------------------------------------------------ #
# Internal helpers
@classmethod
def _get_lock(cls, pool: Dict[str, asyncio.Lock], uuid: str) -> asyncio.Lock:
"""Return a lock for *uuid* from *pool*, creating one if absent."""
def _get_lock(cls, pool: Dict[str, threading.Lock], uuid: str) -> threading.Lock:
"""Return a lock for *uuid* from *pool*, creating one if absent.

Locks are ``threading.Lock`` rather than ``asyncio.Lock`` (see the pool
declarations): the guarded sections are synchronous and correct locking
must work from any event loop.
"""
if uuid not in pool:
pool[uuid] = asyncio.Lock()
pool[uuid] = threading.Lock()
return pool[uuid]

@staticmethod
Expand Down Expand Up @@ -444,7 +464,7 @@ def _has_unfinished_descendants(agent_uuid: str) -> bool:
# Acquire the spawn lock for the primary coder to serialise with
# concurrent spawn operations that also hold this lock.
parent_coder = parent if parent is not None else self.coder
async with self._get_lock(self._spawn_locks, parent_coder.uuid):
with self._get_lock(self._spawn_locks, parent_coder.uuid):
for coder_uuid in list(self._sub_agent_order):
info = self.sub_agents.get(coder_uuid)
if (
Expand Down Expand Up @@ -593,7 +613,7 @@ async def _create_sub_agent_coder(
# tried to acquire the same lock).
parent_coder = parent if parent is not None else self.coder

async with self._get_lock(self._spawn_locks, parent_coder.uuid):
with self._get_lock(self._spawn_locks, parent_coder.uuid):
self._check_max_sub_agents()
new_uuid = str(uuid4()).split("-")[0]

Expand Down Expand Up @@ -686,7 +706,7 @@ async def _create_sub_agent_coder(

# Re-acquire the lock to register — we must re-check max agents since
# the lock was released and other spawns may have registered in between.
async with self._get_lock(self._spawn_locks, parent_coder.uuid):
with self._get_lock(self._spawn_locks, parent_coder.uuid):
self._check_max_sub_agents()

# Register in global coder lookup
Expand Down Expand Up @@ -952,7 +972,7 @@ async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:
else:
return

async with self._get_lock(self._conversation_locks, info.parent_uuid):
with self._get_lock(self._conversation_locks, info.parent_uuid):
ConversationService.get_manager(parent_coder).add_message(
message_dict={"role": "user", "content": user_content},
tag=MessageTag.CUR,
Expand Down Expand Up @@ -1200,8 +1220,11 @@ def _tui_notify(tui, callback, *args):
"""Invoke *callback* on the TUI app, from either thread.

``call_from_thread`` only works when the caller is not the app's own
thread; scheduler dispatch runs on the primary (app) event loop, so fall
back to calling directly, which is correct when already on that thread.
thread. Scheduler-dispatched workflow runs now execute on the coder's own
loop (the TUI worker-thread loop — see ``SchedulerService._on_coder_loop``),
so notifications originate from the worker thread and ``call_from_thread``
applies; when we are already on the app thread (or the TUI is unavailable),
fall back to calling directly.
"""

try:
Expand Down
23 changes: 22 additions & 1 deletion cecli/helpers/io_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,28 @@ async def get_input(self, *args, **kwargs):

async def confirm_ask(self, *args, **kwargs):
"""Forward confirm_ask — per-coder queue iteration is handled by
TextualInputOutput which now iterates all per-coder queues."""
TextualInputOutput which now iterates all per-coder queues.

Coders configured with ``skip_cli_confirmations`` (unattended ``wf:``
workflow and ``ws:`` workspace workers) are auto-confirmed. They run
with nobody watching, and — more importantly — a blocking prompt has
to be awaited from whatever loop the coder happens to run on. A
scheduler-dispatched workflow runs on the main (Textual) loop, so
blocking there used to rebind ``queues``' global input-wake slot and
strand the primary coder's input pump.
"""
coder_ref = getattr(self, "_coder", None)
coder = coder_ref() if coder_ref is not None else None

if coder is not None and getattr(coder, "skip_cli_confirmations", False):
question = args[0] if args else kwargs.get("question", "")
logger.debug(
"auto-confirming %r for unattended coder %s (skip_cli_confirmations)",
question,
self._coder_uuid,
)
return True

if "coder_uuid" not in kwargs:
kwargs["coder_uuid"] = self._coder_uuid
return await self._target.confirm_ask(*args, **kwargs)
Expand Down
2 changes: 1 addition & 1 deletion cecli/helpers/llms/domains/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ def bedrock_endpoint(resolved: Dict[str, Any]) -> str:
)

route = resolved["route"]
return f"https://bedrock-runtime.{region}.amazonaws.com/model/{route}/converse"
return f"{resolved['api_base']}/model/{route}/converse"


def _signed_headers(
Expand Down
51 changes: 33 additions & 18 deletions cecli/helpers/llms/litellm_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import asyncio
import json
import math
import os
import re
import warnings
Expand Down Expand Up @@ -1096,15 +1097,11 @@ def encode(
"""Estimate tokens for ``text`` without loading a BPE tokenizer.

tiktoken's cl100k_base/o200k_base encodings cost ~30-95MB of RSS the
first time they are loaded, so we approximate instead: roughly one token
per four ASCII characters (the classic rule of thumb), plus one token per
non-ASCII character (CJK/emoji tokenize at about one token per char), and
never fewer tokens than there are whitespace-delimited word/punctuation
runs (dense code with short tokens is otherwise undercounted).

The estimate lands within ~±30% of cl100k_base on code and prose, which
is plenty for context-window checks, cost display, and file-size
warnings.
first time they are loaded, so we approximate instead with a regex
pre-tokenizer patterned on OpenAI's o200k_base (see
``_estimate_token_count``). The estimate lands within ~±30% of
cl100k_base on code and prose, which is plenty for context-window
checks, cost display, and file-size warnings.

Returns a ``range`` whose length is the estimated token count; the
individual values are dummy ids (callers only use ``len()``/iteration).
Expand Down Expand Up @@ -1244,23 +1241,41 @@ def _load_litellm(self) -> None:
# Token-count estimator (tiktoken-free)
# ---------------------------------------------------------------------------

_WORD_OR_PUNCT_RE = re.compile(r"\w+|[^\w\s]")
_NON_ASCII_RE = re.compile(r"[^\x00-\x7f]")
# Standard ``re`` approximation of OpenAI's o200k_base pre-tokenizer pattern.
# The letter class is Unicode-aware (``[^\W\d_]``) so non-Latin scripts are
# counted rather than dropped.
_O200K_APPROX_PATTERN = re.compile(
r"""'(?:[sdmt]|ll|ve|re)| ?[^\W\d_]+| ?\d{1,3}| ?[^\s\w]+|\s+""", re.UNICODE
)

#: Bytes charged per token for chunks too long to be a single token, calibrated
#: against o200k_base (~5 bytes/token on code, prose and JSON).
_CHARS_PER_TOKEN = 5.0


def _estimate_token_count(text: str) -> int:
"""Estimate the number of tokens in ``text`` without a BPE tokenizer.

Uses the classic ~4 chars/token rule of thumb, lifted for non-ASCII text
(CJK/emoji tokenize at roughly one token per character) and floored by the
number of word/punctuation runs so dense code with many short tokens isn't
undercounted.
Splits ``text`` with a regex approximation of OpenAI's o200k_base
pre-tokenizer, then accumulates a fractional token weight per chunk:
whitespace runs collapse to a single token, numbers and short chunks
(<= 4 bytes) count as one, and longer chunks are charged at roughly
``_CHARS_PER_TOKEN`` bytes per token. The total is rounded up and floored
at one token for any non-empty input.
"""

if not text:
return 0

units = len(_WORD_OR_PUNCT_RE.findall(text))
non_ascii = len(_NON_ASCII_RE.findall(text))
tokens = 0.0
for chunk in _O200K_APPROX_PATTERN.findall(text):
byte_len = len(chunk.encode("utf-8"))

if chunk.isspace():
tokens += 1.0
elif chunk.strip().isdigit() or byte_len <= 4:
tokens += 1.0
else:
tokens += max(1.0, byte_len / _CHARS_PER_TOKEN)

return max(1, len(text) // 4, units) + non_ascii
return max(1, math.ceil(tokens - 1e-9))
Loading
Loading