Skip to content

Commit 6dc15e3

Browse files
AstrBot Localclaude
andcommitted
feat: refactor agent runner, enhance follow-up, skill manager and history saver
- Remove empty completion retry logic from ToolLoopAgentRunner - Enhance astr_main_agent with additional context handling - Extend follow_up pipeline stage with new processing logic - Add new capabilities to skill_manager - Improve history_saver utility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 65ade02 commit 6dc15e3

5 files changed

Lines changed: 192 additions & 21 deletions

File tree

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,10 @@ async def _iter_llm_responses_with_fallback(
279279
candidate_id,
280280
)
281281
try:
282-
from astrbot.core.agent.context.truncator import ContextTruncator
282+
from astrbot.core.agent.context.truncator import (
283+
ContextTruncator,
284+
)
285+
283286
_truncator = ContextTruncator()
284287
_before_total = len(self.run_context.messages)
285288
# Aggressively halve until small enough (up to 5 rounds)
@@ -292,21 +295,27 @@ async def _iter_llm_responses_with_fallback(
292295
_after = len(self.run_context.messages)
293296
logger.info(
294297
"Forced context truncation round %d: %d -> %d messages.",
295-
_halve_round + 1, _before, _after,
298+
_halve_round + 1,
299+
_before,
300+
_after,
296301
)
297302
if _after <= 4:
298303
break # Can't shrink further
299304
# Retry same candidate
300305
try:
301-
async for resp in self._iter_llm_responses(include_model=idx == 0):
306+
async for resp in self._iter_llm_responses(
307+
include_model=idx == 0
308+
):
302309
if resp.is_chunk:
303310
has_stream_output = True
304311
yield resp
305312
continue
306313
yield resp
307314
logger.info(
308315
"Context truncation succeeded after %d round(s): %d -> %d messages.",
309-
_halve_round + 1, _before_total, _after,
316+
_halve_round + 1,
317+
_before_total,
318+
_after,
310319
)
311320
_compression_success = True
312321
return
@@ -327,13 +336,15 @@ async def _iter_llm_responses_with_fallback(
327336
last_exception = retry_exc
328337
logger.warning(
329338
"Chat Model %s retry after compression failed: %s",
330-
candidate_id, retry_exc,
339+
candidate_id,
340+
retry_exc,
331341
)
332342
break
333343
last_exception = retry_exc
334344
logger.warning(
335345
"Chat Model %s still token-exceeded after round %d, halving again...",
336-
candidate_id, _halve_round + 1,
346+
candidate_id,
347+
_halve_round + 1,
337348
)
338349
continue
339350
except Exception as compress_exc:
@@ -375,15 +386,25 @@ def follow_up(
375386
*,
376387
message_text: str,
377388
) -> FollowUpTicket | None:
378-
"""Queue a follow-up message for the next tool result."""
389+
"""Queue a follow-up message to be injected into the next tool result.
390+
391+
Returns None if the agent is already done (message arrived too late) or
392+
if the message text is empty.
393+
"""
379394
if self.done():
395+
logger.debug("follow_up: agent already done, message discarded.")
380396
return None
381397
text = (message_text or "").strip()
382398
if not text:
383399
return None
384400
ticket = FollowUpTicket(seq=self._follow_up_seq, text=text)
385401
self._follow_up_seq += 1
386402
self._pending_follow_ups.append(ticket)
403+
logger.debug(
404+
"follow_up: queued ticket seq=%d, pending=%d",
405+
ticket.seq,
406+
len(self._pending_follow_ups),
407+
)
387408
return ticket
388409

389410
def _resolve_unconsumed_follow_ups(self) -> None:
@@ -402,14 +423,16 @@ def _consume_follow_up_notice(self) -> str:
402423
for ticket in follow_ups:
403424
ticket.consumed = True
404425
ticket.resolved.set()
426+
405427
follow_up_lines = "\n".join(
406428
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
407429
)
430+
count = len(follow_ups)
431+
plural = "messages" if count > 1 else "message"
408432
return (
409-
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
410-
"was in progress. Prioritize these follow-up instructions in your next "
411-
"actions. In your very next action, briefly acknowledge to the user "
412-
"that their follow-up message(s) were received before continuing.\n"
433+
f"\n\n[FOLLOW-UP x{count}] The user sent {count} {plural} while you were working. "
434+
"Incorporate them as supplementary instructions seamlessly — "
435+
"do NOT stop, restart, or explicitly acknowledge receipt; just act naturally.\n"
413436
f"{follow_up_lines}"
414437
)
415438

@@ -714,7 +737,7 @@ async def step_until_done(
714737
self.run_context.messages.append(
715738
Message(
716739
role="user",
717-
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
740+
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。(Tool call limit reached. Stop using tools and summarize your findings directly for the user.)",
718741
)
719742
)
720743
# 再执行最后一步

astrbot/core/astr_main_agent.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@
5959
from astrbot.core.platform.astr_message_event import AstrMessageEvent
6060
from astrbot.core.provider import Provider
6161
from astrbot.core.provider.entities import ProviderRequest
62-
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
62+
from astrbot.core.skills.skill_manager import (
63+
SkillManager,
64+
build_skills_prompt,
65+
get_skills_fingerprint,
66+
)
6367
from astrbot.core.star.context import Context
6468
from astrbot.core.star.star_handler import star_map
6569
from astrbot.core.tools.cron_tools import (
@@ -353,6 +357,37 @@ async def _ensure_persona_and_skills(
353357
"You cannot use shell or Python to perform skills. "
354358
"If you need to use these capabilities, ask the user to enable Computer Use in the AstrBot WebUI -> Config."
355359
)
360+
361+
# --- Dynamic skill update detection (lightweight) ---
362+
# Compute current fingerprint using only stat() calls (no file I/O).
363+
# If the fingerprint changed since the last turn (stored on the event extra),
364+
# inject a one-line system reminder into extra_user_content_parts so the LLM
365+
# is aware that the skill list may have changed. This avoids rebuilding the
366+
# full system prompt mid-conversation (which many providers ignore anyway).
367+
current_fp = get_skills_fingerprint()
368+
prev_fp = event.get_extra("_skills_fp")
369+
if prev_fp is not None and prev_fp != current_fp:
370+
# Skills changed since the last request in this session — notify the LLM.
371+
skill_names = [s.name for s in skills]
372+
skills_list_str = ", ".join(skill_names) if skill_names else "(none)"
373+
req.extra_user_content_parts.append(
374+
TextPart(
375+
text=(
376+
"<system_reminder>"
377+
"The available skill list has been updated since the last turn. "
378+
f"Current active skills: {skills_list_str}. "
379+
"Please refer to this updated list for any skill-related requests."
380+
"</system_reminder>"
381+
)
382+
)
383+
)
384+
logger.debug(
385+
"Skills fingerprint changed (%s -> %s), injected update reminder.",
386+
prev_fp,
387+
current_fp,
388+
)
389+
event.set_extra("_skills_fp", current_fp)
390+
# --- end dynamic skill update detection ---
356391
tmgr = plugin_context.get_llm_tool_manager()
357392

358393
# inject toolset in the persona

astrbot/core/pipeline/process_stage/follow_up.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,28 @@ def register_active_runner(umo: str, runner: AgentRunner) -> None:
4141
def unregister_active_runner(umo: str, runner: AgentRunner) -> None:
4242
if _ACTIVE_AGENT_RUNNERS.get(umo) is runner:
4343
_ACTIVE_AGENT_RUNNERS.pop(umo, None)
44+
# Best-effort cleanup: if no follow-up state is pending any more, drop the
45+
# UMO entry to avoid accumulating stale entries when sessions end abnormally.
46+
state = _FOLLOW_UP_ORDER_STATE.get(umo)
47+
if state is not None:
48+
statuses = state.get("statuses")
49+
if not statuses:
50+
_FOLLOW_UP_ORDER_STATE.pop(umo, None)
51+
else:
52+
# There are still pending/active entries — notify the condition so
53+
# any waiter in _activate_and_wait_follow_up_turn can re-check and
54+
# potentially hit the timeout branch.
55+
condition = state.get("condition")
56+
if isinstance(condition, asyncio.Condition):
57+
async def _notify_condition(cond: asyncio.Condition) -> None:
58+
async with cond:
59+
cond.notify_all()
60+
try:
61+
loop = asyncio.get_event_loop()
62+
if loop.is_running():
63+
loop.create_task(_notify_condition(condition))
64+
except RuntimeError:
65+
pass
4466

4567

4668
def _get_follow_up_order_state(umo: str) -> dict[str, object]:
@@ -108,6 +130,10 @@ async def _mark_follow_up_consumed(umo: str, seq: int) -> None:
108130
_FOLLOW_UP_ORDER_STATE.pop(umo, None)
109131

110132

133+
_FOLLOW_UP_WAIT_TIMEOUT: float = 30.0
134+
"""Max seconds a follow-up turn will wait for its predecessor to finish."""
135+
136+
111137
async def _activate_and_wait_follow_up_turn(umo: str, seq: int) -> None:
112138
state = _FOLLOW_UP_ORDER_STATE.get(umo)
113139
if not state:
@@ -121,12 +147,27 @@ async def _activate_and_wait_follow_up_turn(umo: str, seq: int) -> None:
121147
statuses[seq] = "active"
122148

123149
# Strict ordering: only the head (`next_turn`) can continue.
150+
# Use a timeout to guard against predecessor runner crashes.
151+
deadline = asyncio.get_event_loop().time() + _FOLLOW_UP_WAIT_TIMEOUT
124152
while True:
125153
next_turn = state["next_turn"]
126154
assert isinstance(next_turn, int)
127155
if next_turn == seq:
128156
break
129-
await condition.wait()
157+
remaining = deadline - asyncio.get_event_loop().time()
158+
if remaining <= 0:
159+
# Predecessor never finished; forcibly advance to avoid permanent hang.
160+
logger.warning(
161+
"Follow-up wait timeout for umo=%s seq=%s; advancing turn to prevent hang.",
162+
umo,
163+
seq,
164+
)
165+
state["next_turn"] = seq
166+
break
167+
try:
168+
await asyncio.wait_for(condition.wait(), timeout=remaining)
169+
except asyncio.TimeoutError:
170+
pass # re-check on next iteration
130171

131172

132173
async def _finish_follow_up_turn(umo: str, seq: int) -> None:
@@ -152,8 +193,19 @@ async def _monitor_follow_up_ticket(
152193
ticket: FollowUpTicket,
153194
order_seq: int,
154195
) -> None:
155-
"""Advance consumed slots immediately on resolution to avoid wake-order drift."""
196+
"""Advance consumed slots immediately on resolution to avoid wake-order drift.
197+
198+
Only marks the order slot consumed here when *ticket* was consumed by the
199+
runner (i.e. injected into a tool-result). If the ticket was *not* consumed
200+
(i.e. the runner finished without ever flushing pending follow-ups),
201+
``prepare_follow_up_capture`` handles the mark via its own branch, so we
202+
must not double-call ``_mark_follow_up_consumed`` here.
203+
"""
156204
await ticket.resolved.wait()
205+
# Guard: only act when consumed=True AND prepare_follow_up_capture has not
206+
# already handled this seq (it sets consumed_marked and calls us via the
207+
# captured branch). The state dict check inside _mark_follow_up_consumed
208+
# is idempotent, so a double-call is safe, but we skip it when not needed.
157209
if ticket.consumed:
158210
await _mark_follow_up_consumed(umo, order_seq)
159211

astrbot/core/skills/skill_manager.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,3 +539,44 @@ def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> st
539539

540540
self.set_skill_active(skill_name, True)
541541
return skill_name
542+
543+
544+
# ---------------------------------------------------------------------------
545+
# Lightweight skill-set fingerprint for dynamic update detection
546+
# ---------------------------------------------------------------------------
547+
548+
def get_skills_fingerprint(skills_root: str | None = None) -> str:
549+
"""Return a lightweight fingerprint of the current skill set.
550+
551+
Uses only os.stat() calls (no file I/O) so it is extremely cheap.
552+
The fingerprint changes whenever:
553+
- skills.json is modified (new skill installed / toggled)
554+
- a new SKILL.md appears or disappears under skills_root
555+
"""
556+
import hashlib
557+
558+
data_path = Path(get_astrbot_data_path())
559+
config_path = str(data_path / SKILLS_CONFIG_FILENAME)
560+
root = Path(skills_root or get_astrbot_skills_path())
561+
562+
parts: list[str] = []
563+
564+
# 1. skills.json mtime (catches installs / enable-disable)
565+
try:
566+
parts.append(str(os.path.getmtime(config_path)))
567+
except OSError:
568+
parts.append("no-config")
569+
570+
# 2. Count of skill dirs that have SKILL.md (catches new folders)
571+
try:
572+
count = sum(
573+
1
574+
for e in root.iterdir()
575+
if e.is_dir() and (e / "SKILL.md").exists()
576+
)
577+
parts.append(str(count))
578+
except OSError:
579+
parts.append("0")
580+
581+
raw = "|".join(parts)
582+
return hashlib.md5(raw.encode()).hexdigest()[:12]

astrbot/core/utils/history_saver.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,39 @@ async def persist_agent_history(
1313
req: ProviderRequest,
1414
summary_note: str,
1515
) -> None:
16-
"""Persist agent interaction into conversation history."""
16+
"""Persist agent interaction into conversation history.
17+
18+
Fix: always re-fetch the latest history from DB before appending, to
19+
avoid the race-condition where a CronJob snapshot overwrites newer
20+
user-bot turns that were saved while the cron was running.
21+
"""
1722
if not req or not req.conversation:
1823
return
1924

20-
history = []
25+
cid = req.conversation.cid
26+
umo = event.unified_msg_origin
27+
28+
# Re-fetch the freshest history from DB (avoids stale-snapshot overwrite)
29+
history: list[dict] = []
2130
try:
22-
history = json.loads(req.conversation.history or "[]")
31+
fresh_conv = await conversation_manager.get_conversation(umo, cid)
32+
if fresh_conv:
33+
history = json.loads(fresh_conv.history or "[]")
34+
else:
35+
# Fallback: use the snapshot embedded in req (better than nothing)
36+
logger.warning(
37+
"persist_agent_history: could not re-fetch conversation %s, "
38+
"falling back to snapshot history.",
39+
cid,
40+
)
41+
history = json.loads(req.conversation.history or "[]")
2342
except Exception as exc: # noqa: BLE001
24-
logger.warning("Failed to parse conversation history: %s", exc)
43+
logger.warning("Failed to load fresh conversation history: %s", exc)
44+
2545
history.append({"role": "user", "content": "Output your last task result below."})
2646
history.append({"role": "assistant", "content": summary_note})
2747
await conversation_manager.update_conversation(
28-
event.unified_msg_origin,
29-
req.conversation.cid,
48+
umo,
49+
cid,
3050
history=history,
3151
)

0 commit comments

Comments
 (0)