Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 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
4bf8efc
test: add run-queue/idle-immediate queue tests, fix RTC-05 lock check
Oct 11, 2026
fb04f19
fix: drain idle prompt queue in run_one command path and run_stream
Oct 12, 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
68187dd
Add ACP v1 stdio agent mode (cecli --acp)
1broseidon Oct 13, 2026
e750db0
feat: drain idle queue in TUI and fix nested command completions
Oct 15, 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
accf42f
cli-69: fix queue bugs
Oct 16, 2026
b8aa5e5
cli-69: updated tests
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
f7e7886
cli-69: updated tests
Oct 16, 2026
1f4d055
Merge branch 'v1.8.1' into feat/acp-stdio
dwash96 Oct 17, 2026
ece2596
Merge PR #714: ACP v1 stdio agent
dwash96 Oct 17, 2026
e276571
Fix ACP skipped tool status and cancellation race
dwash96 Oct 17, 2026
42d4599
Make reasoning effort test independent of provider keys
dwash96 Oct 17, 2026
e687bf1
Merge PR #715: Fix prompt queue processing bugs
dwash96 Oct 17, 2026
85b2610
Apply queue fixes after PR #715
dwash96 Oct 17, 2026
64d00b8
Keep workflow command tasks draining the staged queue
dwash96 Oct 17, 2026
40e2137
Handle commands the update the coder class in prompt queue feature
dwash96 Oct 17, 2026
4af402b
Fix windows test
dwash96 Oct 17, 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
27 changes: 21 additions & 6 deletions cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"file",
"read",
"mcp_servers_files",
"acp_models",
"set_env",
"api_key",
"alias",
Expand Down Expand Up @@ -440,13 +441,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 Expand Up @@ -1317,6 +1314,24 @@ def get_parser(default_config_files, git_root):
help="Specify server configuration (WebSocket and headless) as a JSON/YAML string",
default=None,
)
group.add_argument(
"--acp",
action="store_true",
default=False,
help=(
"Run as an Agent Client Protocol (ACP) agent over stdio, for editors such as Zed"
" (stdout carries the protocol)"
),
)
group.add_argument(
"--acp-models",
action="append",
metavar="PATTERN",
help=(
"Limit the ACP model picker's provider lists to models matching this pattern,"
" matched like /models (can be used multiple times)"
),
)

##########
group = parser.add_argument_group("Deprecated model settings")
Expand Down
1 change: 1 addition & 0 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,7 @@ async def _execute_local_tools(self, tool_calls_list):
for tool_call in tool_calls_list:
tool_name = tool_call.function.name
result_message = ""
self.io.tool_call_event(tool_call.id, "in_progress")
try:
if responses.unprefix_tool_name(tool_name)[1].lower() in self.write_tools:
used_write_tool = True
Expand Down
161 changes: 137 additions & 24 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,8 @@ def _reset_token_usage(self):
commit_language = None
file_watcher = None
mcp_manager = None
run_one_completed = True
compact_context_completed = True
_drain_queue_after_command = False
suppress_announcements_for_next_prompt = False
tool_reflection = False
last_user_message = ""
Expand Down Expand Up @@ -684,6 +684,8 @@ def __init__(
self._queue_counter = 0
self._queue_lock = threading.Lock()
self._processing_queue = False
self._active_queue_prompt = None
self._drain_queue_after_command = False

self.data_cache = {
"repo": {"last_key": "", "read_only_count": None},
Expand Down Expand Up @@ -1627,6 +1629,10 @@ async def run_stream(self, user_message):
async for chunk in self.send_message(user_message):
yield chunk

# Stream-driven interfaces bypass run_one(), so drain the queue here
# to keep queued prompts processing after the request completes.
await self.process_prompt_queue()

def init_before_message(self):
self.coder_edited_files = set()
self.reflected_message = None
Expand Down Expand Up @@ -1946,14 +1952,12 @@ async def generate(self, user_message, preproc):
await self.compact_context_if_needed()
self.compact_context_completed = True

self.run_one_completed = False
await self.run_one(user_message, preproc)
self.show_undo_hint()
except asyncio.CancelledError:
# Don't show undo hint if cancelled
raise
finally:
self.run_one_completed = True
self.compact_context_completed = True
self.io.stop_spinner()
# Trim memory in the background so it doesn't stall the event loop
Expand Down Expand Up @@ -2013,9 +2017,6 @@ async def preproc_user_input(self, inp):
try:
return await self.commands.run(inp, coder=self, **run_kwargs)
finally:
# Dispatch can return early (unknown/ambiguous command) or
# raise without ever running a command; the gate must still be
# reopened or the input/output loops park forever.
self.commands.cmd_running_event.set()

await self.check_for_file_mentions(inp)
Expand All @@ -2026,6 +2027,67 @@ async def preproc_user_input(self, inp):
def wrap_user_input(self, inp):
return inp

async def process_prompt_queue(self, preproc=True):
"""Drain the coder's prompt queue in FIFO order.

Re-entrant calls return immediately. Each item is activated before it
runs so an interrupt can put it back at the front of the queue, and
failures are isolated so one prompt does not abort the rest.
"""
if self._processing_queue:
return

def warn_paused():
remaining = command_queue.get_queue_length(self)
self.io.tool_warning(
f"Queue processing interrupted. {remaining} prompt(s) remain"
" queued; use /run-queue to resume."
)

from cecli.helpers.agents.service import AgentService

self._processing_queue = True
try:
while True:
item = command_queue.dequeue_for_processing(self)
if item is None:
break

self.io.tool_output(f"Processing queued prompt (id: {item['id']})...")
try:
await self.generate(item["text"], preproc)
interrupt_event = getattr(self, "interrupt_event", None)
if interrupt_event is not None and interrupt_event.is_set() is True:
command_queue.requeue_active_prompt(self)
warn_paused()
break
except (asyncio.CancelledError, KeyboardInterrupt):
command_queue.requeue_active_prompt(self)
warn_paused()
raise
except SwitchCoderSignal as switch:
if not getattr(self, "parent_uuid", ""):
raise

service = AgentService.get_instance(self)
info = service.sub_agents.get(self.uuid)
if info is None:
raise

await service.switch_sub_agent_coder(info, switch, preproc)
return
except SystemExit:
raise
except Exception as e:
command_queue.clear_active_prompt(self)
self.io.tool_error(f"Error processing queued prompt (id: {item['id']}): {e}")
continue

command_queue.clear_active_prompt(self)
finally:
self._drain_queue_after_command = False
command_queue.finish_processing(self)

async def run_one(self, user_message, preproc):
self.init_before_message()

Expand All @@ -2041,6 +2103,9 @@ async def run_one(self, user_message, preproc):
if self.commands.is_command(user_message) and not self.commands.is_test_command(
user_message
):
if self._processing_queue or self._drain_queue_after_command:
self._drain_queue_after_command = False
await self.process_prompt_queue(preproc)
return

if not self.commands.is_command(user_message):
Expand Down Expand Up @@ -2112,19 +2177,10 @@ async def run_one(self, user_message, preproc):

await self.auto_save_session(force=True)

# Move to the next queued prompt (CLI-33) only after the current message
# has fully completed, so the queue drains within run_one() instead of
# Drain any queued prompts (CLI-33) only after the current message has
# fully completed, so the queue is processed within run_one() instead of
# being watched by the generation loops.
if self.prompt_queue and not self._processing_queue:
self._processing_queue = True
try:
item = command_queue.dequeue_prompt(self)
finally:
self._processing_queue = False

if item is not None:
self.io.tool_output(f"Processing queued prompt (id: {item['id']})...")
await self.run_one(item["text"], preproc)
await self.process_prompt_queue(preproc)

if not await HookIntegration.call_end_hooks(self):
self.io.tool_warning("Execution stopped by end hook")
Expand Down Expand Up @@ -2253,6 +2309,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 +2773,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 +2784,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 @@ -3259,6 +3340,7 @@ async def _execute_mcp_tools(self, server, tool_calls):
continue

tool_id_set.add(tool_call.id)
self.io.tool_call_event(tool_call.id, "in_progress")

try:
# Arguments can be a stream of JSON objects.
Expand Down Expand Up @@ -3465,6 +3547,11 @@ async def process_tool_calls(self, tool_call_response):
for server, server_responses in tool_responses_by_server.items():
for tool_response in server_responses:
tool_responses.append(tool_response)
self.io.tool_call_event(
tool_response["tool_call_id"],
"completed",
result=tool_response.get("content"),
)

ConversationService.get_manager(self).add_message(
message_dict=tool_response,
Expand All @@ -3482,6 +3569,12 @@ def _print_tool_call_info(self, server_tool_calls):

for server, tool_calls in server_tool_calls.items():
for tool_call in tool_calls:
self.io.tool_call_event(
tool_call.id,
"pending",
name=tool_call.function.name,
arguments=tool_call.function.arguments,
)
try:
if ToolRegistry.get_tool(tool_call.function.name.lower()):
ToolRegistry.get_tool(tool_call.function.name.lower()).format_output(
Expand Down Expand Up @@ -3996,7 +4089,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 +4219,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 +4296,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 @@ -4795,7 +4896,7 @@ def show_usage_report(self):
if self.tui and self.tui():
self.tui().update_cost(self.usage_report.replace("\n", " "))
else:
self.io.tool_output(self.usage_report)
self.io.tool_output(self.usage_report, type="usage-report")
self.io.rule()

self.message_cost = 0.0
Expand Down Expand Up @@ -5391,3 +5492,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)
3 changes: 3 additions & 0 deletions cecli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
from .reset import ResetCommand
from .rules import RulesCommand
from .run import RunCommand
from .run_queue import RunQueueCommand
from .save import SaveCommand
from .save_session import SaveSessionCommand
from .search_memory import SearchMemoryCommand
Expand Down Expand Up @@ -169,6 +170,7 @@
CommandRegistry.register(ResetCommand)
CommandRegistry.register(RulesCommand)
CommandRegistry.register(RunCommand)
CommandRegistry.register(RunQueueCommand)
CommandRegistry.register(SaveCommand)
CommandRegistry.register(SaveSessionCommand)
CommandRegistry.register(SearchMemoryCommand)
Expand Down Expand Up @@ -265,6 +267,7 @@
"ResetCommand",
"RulesCommand",
"RunCommand",
"RunQueueCommand",
"SaveCommand",
"SaveSessionCommand",
"SearchMemoryCommand",
Expand Down
5 changes: 4 additions & 1 deletion cecli/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
class Commands:
scraper = None

# Commands that receive their full argument text for nested completion.
FULL_ARGS_COMPLETION_COMMANDS = ("/queue", "/insert-queue")

def _get_coder(self):
"""Return coder via weak reference, or None if collected."""
if self._coder_ref is not None:
Expand Down Expand Up @@ -90,7 +93,7 @@ def __init__(
self.last_command_show_notification = True

# Commands that should NOT trigger auto-processing of the queue
self._MANAGEMENT_COMMANDS = {"queue", "list-queue", "remove-queue"}
self._MANAGEMENT_COMMANDS = {"queue", "list-queue", "remove-queue", "run-queue"}

def _load_custom_commands(self, custom_commands):
"""
Expand Down
Loading
Loading