Skip to content

Commit 5ca49fc

Browse files
author
CraftBot
committed
Merge remote-tracking branch 'origin/V1.4.2' into improvement/llm-providers-upgrade
2 parents 5349ff7 + fd30f56 commit 5ca49fc

427 files changed

Lines changed: 60397 additions & 28641 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,6 @@ agent_file_system/ACTIONS.md
5959
agent_bundle/
6060
**/.craftbot/
6161
app/data/.file_index/
62-
.playwright-mcp
62+
.playwright-mcp
63+
# Sidecar Node runtime (install.py downloads it when the system Node is too old for Living UI)
64+
runtime/

agent_core/core/event_stream/event.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ class Event:
142142
uses it to keep the run's "Working…" indicator up across the
143143
bubble instead of treating every agent bubble as a run-ending
144144
reply. None/False for final replies and non-chat events.
145+
question: For AGENT_MESSAGE events only: set when the message is a
146+
question to the user with suggested responses (send_message with
147+
suggested_responses). Shape:
148+
``{"options": ["Yes", "No"], "allow_free_text": true}``. The UI
149+
renders it as answer chips plus a pinned question box above the
150+
chat composer. None for ordinary messages.
145151
"""
146152

147153
message: str
@@ -157,6 +163,7 @@ class Event:
157163
action_output: Optional[Dict[str, Any]] = None
158164
platform: Optional[str] = None
159165
continue_work: Optional[bool] = None
166+
question: Optional[Dict[str, Any]] = None
160167

161168
def display_text(self) -> Optional[str]:
162169
"""
@@ -189,6 +196,7 @@ def to_dict(self) -> Dict[str, Any]:
189196
"action_output": self.action_output,
190197
"platform": self.platform,
191198
"continue_work": self.continue_work,
199+
"question": self.question,
192200
}
193201

194202
@classmethod
@@ -228,6 +236,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Event":
228236
action_output=data.get("action_output"),
229237
platform=data.get("platform"),
230238
continue_work=data.get("continue_work"),
239+
question=data.get("question"),
231240
)
232241

233242
@property
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Execution-scoped context for in-process actions.
2+
3+
``current_input_data`` holds the full ``input_data`` dict of the action
4+
currently executing in this context. It exists so cross-cutting helpers
5+
deep inside an action's call tree (e.g. multi-account routing reading the
6+
``account`` hint) can see routing keys without threading them through
7+
every action function signature.
8+
9+
Scope rules:
10+
- Set only by the internal executors (``_atomic_action_internal*``),
11+
reset in a ``finally`` — never leaks across actions.
12+
- Sync actions run in a thread pool where the caller's context does NOT
13+
propagate, so the executor wraps the call and sets the var inside the
14+
worker thread (see ``run_with_input_context``).
15+
- Sandboxed (subprocess) actions cannot see it at all — helpers must
16+
treat a ``None`` value as "no context available".
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from contextvars import ContextVar
22+
from typing import Any, Callable, Dict, Optional
23+
24+
current_input_data: ContextVar[Optional[Dict[str, Any]]] = ContextVar(
25+
"current_input_data", default=None
26+
)
27+
28+
29+
def run_with_input_context(
30+
function_to_call: Callable[[dict], dict], input_data: dict
31+
) -> dict:
32+
"""Call a sync action with ``current_input_data`` set for its duration.
33+
34+
Used as the thread-pool target: the worker thread has its own context,
35+
so the var must be set (and reset) inside the thread, not the caller.
36+
"""
37+
token = current_input_data.set(input_data)
38+
try:
39+
return function_to_call(input_data)
40+
finally:
41+
current_input_data.reset(token)

agent_core/core/impl/action/executor.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,9 @@ def _atomic_action_internal(
571571
"The action_code string did not define a callable Python function."
572572
)
573573

574-
execution_result = function_to_call(input_data)
574+
from agent_core.core.impl.action.context import run_with_input_context
575+
576+
execution_result = run_with_input_context(function_to_call, input_data)
575577
return execution_result
576578

577579
except Exception as e:
@@ -618,16 +620,29 @@ async def _atomic_action_internal_async(
618620
"The action_code string did not define a callable Python function."
619621
)
620622

623+
from agent_core.core.impl.action.context import (
624+
current_input_data,
625+
run_with_input_context,
626+
)
627+
621628
# Check if the function is async (coroutine function)
622629
if inspect.iscoroutinefunction(function_to_call):
623630
logger.debug(f"[ASYNC] Action '{action_name}' is async, awaiting directly")
624-
execution_result = await function_to_call(input_data)
631+
ctx_token = current_input_data.set(input_data)
632+
try:
633+
execution_result = await function_to_call(input_data)
634+
finally:
635+
current_input_data.reset(ctx_token)
625636
else:
626-
# Sync function - run in thread pool to avoid blocking
637+
# Sync function - run in thread pool to avoid blocking. The
638+
# worker thread doesn't inherit this context, so the wrapper
639+
# sets current_input_data inside the thread.
627640
logger.debug(
628641
f"[SYNC] Action '{action_name}' is sync, running in thread pool"
629642
)
630-
thread_future = THREAD_POOL.submit(function_to_call, input_data)
643+
thread_future = THREAD_POOL.submit(
644+
run_with_input_context, function_to_call, input_data
645+
)
631646
try:
632647
execution_result = await asyncio.wrap_future(thread_future)
633648
except asyncio.CancelledError:

agent_core/core/impl/action/manager.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,43 @@ async def _compat_wait_for(fut, timeout):
9999

100100
nest_asyncio.apply()
101101

102+
# ============================================================================
103+
# Second half of the nest_asyncio/3.14 shim: heal asyncio.current_task().
104+
# nest_asyncio forces the PURE-PYTHON asyncio.Task class, whose tasks
105+
# register in the Python-side registry (asyncio.tasks._py_current_task) —
106+
# but asyncio.current_task stays bound to the C-accelerated registry, so it
107+
# returns None inside EVERY task, on EVERY loop, process-wide. Everything
108+
# built on `async with asyncio.timeout(...)` then dies with "Timeout
109+
# (context manager) should be used inside a task" — most visibly the entire
110+
# aiohttp CLIENT (every request enters a timeout context), which is what
111+
# broke the external A2App adapter self-check on 2026-08-24 while the
112+
# aiohttp SERVER (no timeout context on the request path) kept working.
113+
# Rebinding current_task to the Python registry fixes timeout/aiohttp under
114+
# both plain awaits and nested re-entry (verified on 3.14.7 + aiohttp
115+
# 3.14.3). The wait_for replacement above stays: its explicit
116+
# cancellation-wait semantics are load-bearing for force-stop (PR #410).
117+
try:
118+
import _asyncio as _compat_c_asyncio
119+
120+
if asyncio.Task is not getattr(_compat_c_asyncio, "Task", None) and hasattr(
121+
asyncio.tasks, "_py_current_task"
122+
):
123+
asyncio.current_task = asyncio.tasks._py_current_task
124+
asyncio.tasks.current_task = asyncio.tasks._py_current_task
125+
try:
126+
_compat_sys.stderr.write(
127+
"[compat-shim] asyncio.current_task routed to the Python "
128+
"task registry (action/manager)\n"
129+
)
130+
_compat_sys.stderr.flush()
131+
except Exception:
132+
pass
133+
except Exception as _compat_ct_exc:
134+
logger.warning(
135+
f"[compat-shim] current_task rebinding skipped: {_compat_ct_exc!r}"
136+
)
137+
# ============================================================================
138+
102139

103140
def _to_pretty_json(value: Any) -> str:
104141
"""Serialize a value to pretty-printed JSON for readable logs and event streams."""
@@ -247,10 +284,7 @@ async def execute_action(
247284
# re-execute work the ledger shows as already completed (or as
248285
# interrupted mid-flight, where the effect may have happened).
249286
idem_key = None
250-
# if getattr(action, "irreversible", False) and self._idempotency_guard:
251-
252-
# TODO: Temporary turning idempotency guard off.
253-
if 1 == 0:
287+
if getattr(action, "irreversible", False) and self._idempotency_guard:
254288
try:
255289
decision = self._idempotency_guard.begin(
256290
action.name, input_data, session_id

agent_core/core/impl/event_stream/event_stream.py

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
# leaving the action displayed as "running" forever.
4242
MIN_KEEP_RECENT_EVENTS = 2
4343

44+
# Smallest fold worth an LLM call. Summarization is a blocking ~15s round trip;
45+
# collapsing a couple of hundred tokens with one is a straight loss and the
46+
# threshold is breached again on the very next event, so we prune instead.
47+
MIN_FOLD_TOKENS = 2000
48+
4449
# Event kinds that summarization must NEVER collapse — they are kept verbatim in
4550
# tail_events forever, so the contract they carry survives any number of
4651
# summarization passes. `requirements` (from set_requirement) defines the task's
@@ -217,6 +222,7 @@ def log(
217222
action_output: Optional[dict] = None,
218223
platform: Optional[str] = None,
219224
continue_work: Optional[bool] = None,
225+
question: Optional[dict] = None,
220226
) -> int:
221227
"""
222228
Append a new event to the stream and trigger summarization if needed.
@@ -249,6 +255,9 @@ def log(
249255
continue_work: For AGENT_MESSAGE events: True when this is a
250256
mid-run progress update and the agent keeps working after
251257
sending it (drives the UI's persistent "Working…" row).
258+
question: For AGENT_MESSAGE events: suggested-response payload
259+
(``{"options": [...], "allow_free_text": bool}``) when the
260+
message is a question the UI should pin above the composer.
252261
253262
Returns:
254263
The zero-based index of the event within ``tail_events``.
@@ -270,6 +279,7 @@ def log(
270279
action_output=action_output,
271280
platform=platform,
272281
continue_work=continue_work,
282+
question=question,
273283
)
274284
rec = EventRecord(event=ev)
275285

@@ -298,9 +308,19 @@ def log_action_end(self, name: str, status: str, extra: str = "") -> int:
298308
# ───────────────────── summarization & pruning ───────────────────────
299309

300310
def _externalize_message(
301-
self, message: str, *, action_name: str | None = None
311+
self,
312+
message: str,
313+
*,
314+
action_name: str | None = None,
315+
force: bool = False,
302316
) -> str:
303-
"""Persist overly long messages to a temp file and return a pointer event."""
317+
"""Persist overly long messages to a temp file and return a pointer event.
318+
319+
`force` overrides the retrieval-action exemption below. It is used by
320+
`_shrink_pinned_oversize`, where the agent has already consumed the
321+
content in its own turn and the only thing left to do with an oversized
322+
event is stop paying for it every prompt.
323+
"""
304324
if len(message) <= MAX_EVENT_INLINE_CHARS or self.temp_dir is None:
305325
return message
306326

@@ -309,7 +329,12 @@ def _externalize_message(
309329
# send the agent chasing a pointer to a pointer. ("grep" / "stream
310330
# read" are legacy names kept for safety; the live actions are
311331
# grep_files / read_file.)
312-
if action_name in ("grep_files", "read_file", "grep", "stream read"):
332+
if not force and action_name in (
333+
"grep_files",
334+
"read_file",
335+
"grep",
336+
"stream read",
337+
):
313338
return message
314339

315340
try:
@@ -388,6 +413,53 @@ def _find_token_cutoff(self, events: List[EventRecord], keep_tokens: int) -> int
388413
)
389414
return cutoff
390415

416+
def _shrink_pinned_oversize(self, cutoff: int) -> int:
417+
"""Externalize oversized events in the surviving tail, in place.
418+
419+
MIN_KEEP_RECENT_EVENTS pins the newest events so the UI (which mirrors
420+
`tail_events`) never loses an `action_end` in the tick it arrives — an
421+
action purged that early renders as "running" forever. But the pin is
422+
blind to size: when a retrieval action returns a huge payload (grep_files
423+
and read_file are exempt from log-time externalization, because they ARE
424+
how the agent reads externalized content back), the pin holds tens of
425+
thousands of tokens verbatim and a summarization pass cannot get under
426+
the threshold. The next event re-triggers it and the SAME chunk gets
427+
folded on the second try — one entirely wasted blocking LLM call per
428+
oversized event.
429+
430+
Shrinking in place satisfies both constraints: the record survives with
431+
its `action_id` intact so the UI still pairs start↔end, and its message
432+
becomes a pointer the agent can re-read on demand. Caller holds the lock.
433+
434+
Returns the number of tokens reclaimed.
435+
"""
436+
if self.temp_dir is None:
437+
return 0
438+
439+
reclaimed = 0
440+
for rec in self.tail_events[cutoff:]:
441+
message = rec.event.message
442+
if len(message) <= MAX_EVENT_INLINE_CHARS:
443+
continue
444+
pointer = self._externalize_message(
445+
message, action_name=rec.event.action_name, force=True
446+
)
447+
if pointer is message:
448+
# Externalization failed (already logged); leave the event alone.
449+
continue
450+
before = get_cached_token_count(rec)
451+
rec.event.message = pointer
452+
rec._cached_tokens = None
453+
reclaimed += before - get_cached_token_count(rec)
454+
455+
if reclaimed:
456+
self._total_tokens -= reclaimed
457+
logger.info(
458+
f"[EventStream] Collapsed oversized pinned event(s) in place, "
459+
f"reclaiming {reclaimed} tokens (now {self._total_tokens})"
460+
)
461+
return reclaimed
462+
391463
def summarize_by_LLM(self) -> None:
392464
"""
393465
Summarize the oldest tail events using the language model.
@@ -406,6 +478,17 @@ def summarize_by_LLM(self) -> None:
406478
self.tail_events, self.tail_keep_after_summarize_tokens
407479
)
408480

481+
# Collapse anything oversized that the recent-event pin is holding
482+
# verbatim BEFORE deciding whether an LLM call is warranted — that alone
483+
# often drops the stream back under the threshold for free.
484+
if self._shrink_pinned_oversize(cutoff):
485+
if self._total_tokens < self.summarize_at_tokens:
486+
return
487+
# Budget changed; the fold boundary moves with it.
488+
cutoff = self._find_token_cutoff(
489+
self.tail_events, self.tail_keep_after_summarize_tokens
490+
)
491+
409492
if cutoff <= 0:
410493
# Nothing old enough to summarize
411494
return
@@ -419,6 +502,29 @@ def summarize_by_LLM(self) -> None:
419502
# Everything old enough to summarize is protected — nothing to collapse.
420503
return
421504

505+
chunk_tokens = sum(get_cached_token_count(r) for r in chunk)
506+
if chunk_tokens < MIN_FOLD_TOKENS:
507+
# The foldable region is smaller than the LLM call is worth — the tail
508+
# is dominated by events we're required to keep (protected kinds, or
509+
# the recent-event pin). Prune the chunk without a summary rather than
510+
# burn ~15s and a full prompt to reclaim a rounding error. Losing this
511+
# little detail is cheaper than the alternative, which is re-triggering
512+
# on every subsequent log() call.
513+
logger.warning(
514+
f"[EventStream] Foldable region is only {chunk_tokens} tokens "
515+
f"(< {MIN_FOLD_TOKENS}); pruning {len(chunk)} event(s) without an "
516+
f"LLM call. Tail is dominated by pinned/protected events."
517+
)
518+
self._total_tokens -= chunk_tokens
519+
self.tail_events = protected + self.tail_events[cutoff:]
520+
self._append_summarization_notice(
521+
folded_events=len(chunk),
522+
folded_tokens=chunk_tokens,
523+
summary=None,
524+
)
525+
self._session_sync_points.clear()
526+
return
527+
422528
first_ts = chunk[0].ts
423529
last_ts = chunk[-1].ts
424530
window = f"{first_ts.isoformat()} to {last_ts.isoformat()}"
@@ -448,8 +554,13 @@ def summarize_by_LLM(self) -> None:
448554
logger.info(
449555
f"[EventStream] Running synchronous summarization ({self._total_tokens} tokens)"
450556
)
557+
# json_mode=False: this prompt asks for a prose summary, and
558+
# forcing a provider's JSON mode onto it degenerates (DeepSeek
559+
# returns whitespace-only output that reads as empty).
451560
llm_output = self.llm.generate_response(
452-
user_prompt=prompt, prompt_name="EVENT_STREAM_SUMMARIZATION"
561+
user_prompt=prompt,
562+
prompt_name="EVENT_STREAM_SUMMARIZATION",
563+
json_mode=False,
453564
)
454565
new_summary = (llm_output or "").strip()
455566

@@ -465,8 +576,8 @@ def summarize_by_LLM(self) -> None:
465576

466577
# Apply summary and prune events
467578
self.head_summary = new_summary
468-
# Calculate tokens being removed from the snapshotted chunk
469-
removed_tokens = sum(get_cached_token_count(r) for r in chunk)
579+
# Tokens being removed from the snapshotted chunk (measured above).
580+
removed_tokens = chunk_tokens
470581
self._total_tokens -= removed_tokens
471582
# Keep protected events verbatim at the front of the surviving tail.
472583
self.tail_events = protected + self.tail_events[cutoff:]
@@ -492,7 +603,7 @@ def summarize_by_LLM(self) -> None:
492603
# Fallback: drop the oldest chunk without generating a summary so that
493604
# _total_tokens falls below the threshold. Without this, every subsequent
494605
# log() call would immediately re-trigger summarization and flood the logs.
495-
removed_tokens = sum(get_cached_token_count(r) for r in chunk)
606+
removed_tokens = chunk_tokens
496607
self._total_tokens -= removed_tokens
497608
# Keep protected events verbatim even on the no-LLM prune fallback.
498609
self.tail_events = protected + self.tail_events[cutoff:]

0 commit comments

Comments
 (0)