mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
core/agent: append-only directive checkpoints on token bands
This commit is contained in:
+101
-3
@@ -17,6 +17,10 @@ from .budget import (
|
|||||||
DEFAULT_TOOL_RESULT_MAX_CHARS,
|
DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||||
estimate_messages_tokens,
|
estimate_messages_tokens,
|
||||||
)
|
)
|
||||||
|
from .directive_checkpoint import (
|
||||||
|
DEFAULT_DIRECTIVE_REMINDER_TOKENS,
|
||||||
|
parse_checkpoint_bands,
|
||||||
|
)
|
||||||
from .events import UsageEvent
|
from .events import UsageEvent
|
||||||
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
||||||
from .todo_nag import (
|
from .todo_nag import (
|
||||||
@@ -128,11 +132,15 @@ class ChatAgent:
|
|||||||
max_context_tokens: int
|
max_context_tokens: int
|
||||||
todo_stack: TodoStack | None
|
todo_stack: TodoStack | None
|
||||||
todo_nag_strategy: TodoNagStrategy
|
todo_nag_strategy: TodoNagStrategy
|
||||||
|
directive_reminder_tokens: int
|
||||||
|
directive_reminder_text: str | None
|
||||||
messages: list[AnyChatMessage]
|
messages: list[AnyChatMessage]
|
||||||
session_usage: TokenUsage
|
session_usage: TokenUsage
|
||||||
last_turn_usage: TokenUsage
|
last_turn_usage: TokenUsage
|
||||||
last_request_usage: TokenUsage
|
last_request_usage: TokenUsage
|
||||||
last_turn_rounds: int
|
last_turn_rounds: int
|
||||||
|
peak_prompt_tokens: int
|
||||||
|
reminder_last_band: int
|
||||||
# Index into messages of the first unpersisted message (checkpoint cursor).
|
# Index into messages of the first unpersisted message (checkpoint cursor).
|
||||||
_persist_from: int
|
_persist_from: int
|
||||||
|
|
||||||
@@ -155,6 +163,10 @@ class ChatAgent:
|
|||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
todo_stack: TodoStack | None = None,
|
todo_stack: TodoStack | None = None,
|
||||||
todo_nag_strategy: str | TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
todo_nag_strategy: str | TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
||||||
|
directive_reminder_tokens: int = DEFAULT_DIRECTIVE_REMINDER_TOKENS,
|
||||||
|
directive_reminder_text: str | None = None,
|
||||||
|
peak_prompt_tokens: int = 0,
|
||||||
|
reminder_last_band: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -171,13 +183,19 @@ class ChatAgent:
|
|||||||
self.max_context_tokens = max_context_tokens
|
self.max_context_tokens = max_context_tokens
|
||||||
self.todo_stack = todo_stack
|
self.todo_stack = todo_stack
|
||||||
self.todo_nag_strategy = parse_todo_nag_strategy(str(todo_nag_strategy))
|
self.todo_nag_strategy = parse_todo_nag_strategy(str(todo_nag_strategy))
|
||||||
|
self.directive_reminder_tokens = max(0, int(directive_reminder_tokens))
|
||||||
|
text = directive_reminder_text.strip() if directive_reminder_text else ""
|
||||||
|
self.directive_reminder_text = text or None
|
||||||
self.messages = list(messages) if messages is not None else []
|
self.messages = list(messages) if messages is not None else []
|
||||||
self.session_usage = TokenUsage()
|
self.session_usage = TokenUsage()
|
||||||
self.last_turn_usage = TokenUsage()
|
self.last_turn_usage = TokenUsage()
|
||||||
self.last_request_usage = TokenUsage()
|
self.last_request_usage = TokenUsage()
|
||||||
self.last_turn_rounds = 0
|
self.last_turn_rounds = 0
|
||||||
|
self.peak_prompt_tokens = max(0, int(peak_prompt_tokens))
|
||||||
|
self.reminder_last_band = max(0, int(reminder_last_band))
|
||||||
self._persist_from = len(self.messages)
|
self._persist_from = len(self.messages)
|
||||||
self._ensure_system_prompt()
|
self._ensure_system_prompt()
|
||||||
|
self._sync_reminder_band_from_messages()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_retry_text(self) -> str | None:
|
def pending_retry_text(self) -> str | None:
|
||||||
@@ -214,6 +232,53 @@ class ChatAgent:
|
|||||||
# already pointed past stored messages stay correct after insert.
|
# already pointed past stored messages stay correct after insert.
|
||||||
self._persist_from = min(len(self.messages), self._persist_from + 1)
|
self._persist_from = min(len(self.messages), self._persist_from + 1)
|
||||||
|
|
||||||
|
def _sync_reminder_band_from_messages(self) -> None:
|
||||||
|
"""Raise :attr:`reminder_last_band` to match durable checkpoint markers."""
|
||||||
|
from_history = parse_checkpoint_bands(self.messages)
|
||||||
|
self.reminder_last_band = max(self.reminder_last_band, from_history)
|
||||||
|
|
||||||
|
def apply_session_context_usage(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
last_prompt_tokens: int | None = None,
|
||||||
|
peak_prompt_tokens: int | None = None,
|
||||||
|
last_completion_tokens: int | None = None,
|
||||||
|
usage_source: str | None = None,
|
||||||
|
reminder_last_band: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Hydrate usage / reminder band from a session row (resume)."""
|
||||||
|
if peak_prompt_tokens is not None:
|
||||||
|
self.peak_prompt_tokens = max(self.peak_prompt_tokens, int(peak_prompt_tokens))
|
||||||
|
if reminder_last_band is not None:
|
||||||
|
self.reminder_last_band = max(self.reminder_last_band, int(reminder_last_band))
|
||||||
|
if last_prompt_tokens is not None and last_prompt_tokens > 0:
|
||||||
|
source = usage_source or "api"
|
||||||
|
self.last_request_usage = TokenUsage(
|
||||||
|
prompt_tokens=int(last_prompt_tokens),
|
||||||
|
completion_tokens=int(last_completion_tokens or 0),
|
||||||
|
total_tokens=int(last_prompt_tokens) + int(last_completion_tokens or 0),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
self.peak_prompt_tokens = max(self.peak_prompt_tokens, int(last_prompt_tokens))
|
||||||
|
self._sync_reminder_band_from_messages()
|
||||||
|
|
||||||
|
async def _persist_context_usage(self) -> None:
|
||||||
|
if self.memory is None or self.session_id is None:
|
||||||
|
return
|
||||||
|
last = self.last_request_usage
|
||||||
|
_ = await self.memory.update_session_context_usage(
|
||||||
|
self.session_id,
|
||||||
|
last_prompt_tokens=last.prompt_tokens if not last.is_zero() else None,
|
||||||
|
peak_prompt_tokens=self.peak_prompt_tokens or None,
|
||||||
|
last_completion_tokens=last.completion_tokens if not last.is_zero() else None,
|
||||||
|
usage_source=last.source if not last.is_zero() else None,
|
||||||
|
reminder_last_band=self.reminder_last_band,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _on_reminder_band(self, band: int) -> None:
|
||||||
|
self.reminder_last_band = max(self.reminder_last_band, band)
|
||||||
|
await self._persist_context_usage()
|
||||||
|
|
||||||
def replace_messages(
|
def replace_messages(
|
||||||
self,
|
self,
|
||||||
messages: Sequence[AnyChatMessage],
|
messages: Sequence[AnyChatMessage],
|
||||||
@@ -233,6 +298,7 @@ class ChatAgent:
|
|||||||
else:
|
else:
|
||||||
self._persist_from = len(self.messages) if persisted else 0
|
self._persist_from = len(self.messages) if persisted else 0
|
||||||
self._ensure_system_prompt()
|
self._ensure_system_prompt()
|
||||||
|
self._sync_reminder_band_from_messages()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def persist_from(self) -> int:
|
def persist_from(self) -> int:
|
||||||
@@ -246,6 +312,15 @@ class ChatAgent:
|
|||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
loaded = await self.memory.list_messages(self.session_id)
|
loaded = await self.memory.list_messages(self.session_id)
|
||||||
self.replace_messages(loaded, persisted=True)
|
self.replace_messages(loaded, persisted=True)
|
||||||
|
row = await self.memory.get_session(self.session_id)
|
||||||
|
if row is not None:
|
||||||
|
self.apply_session_context_usage(
|
||||||
|
last_prompt_tokens=row.last_prompt_tokens,
|
||||||
|
peak_prompt_tokens=row.peak_prompt_tokens,
|
||||||
|
last_completion_tokens=row.last_completion_tokens,
|
||||||
|
usage_source=row.usage_source,
|
||||||
|
reminder_last_band=row.reminder_last_band,
|
||||||
|
)
|
||||||
|
|
||||||
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
|
async def bind_session(self, session_id: int, *, load: bool = True) -> None:
|
||||||
"""Attach a memory session id; optionally load existing messages."""
|
"""Attach a memory session id; optionally load existing messages."""
|
||||||
@@ -295,6 +370,15 @@ class ChatAgent:
|
|||||||
end = committed_prefix_end(self.messages, user_index)
|
end = committed_prefix_end(self.messages, user_index)
|
||||||
del self.messages[end:]
|
del self.messages[end:]
|
||||||
|
|
||||||
|
def _developer_tail_end(self, start: int) -> int:
|
||||||
|
"""Extend *start* through trailing developer checkpoint messages."""
|
||||||
|
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
|
||||||
|
|
||||||
|
end = start
|
||||||
|
while end < len(self.messages) and isinstance(self.messages[end], DeveloperChatMessage):
|
||||||
|
end += 1
|
||||||
|
return end
|
||||||
|
|
||||||
async def _run_from_user_message(self, user_msg: UserChatMessage) -> AsyncIterator[AgentEvent]:
|
async def _run_from_user_message(self, user_msg: UserChatMessage) -> AsyncIterator[AgentEvent]:
|
||||||
"""Run the tool loop for an already-appended user message.
|
"""Run the tool loop for an already-appended user message.
|
||||||
|
|
||||||
@@ -340,17 +424,29 @@ class ChatAgent:
|
|||||||
max_context_tokens=self.max_context_tokens,
|
max_context_tokens=self.max_context_tokens,
|
||||||
todo_stack=self.todo_stack,
|
todo_stack=self.todo_stack,
|
||||||
todo_nag_strategy=self.todo_nag_strategy,
|
todo_nag_strategy=self.todo_nag_strategy,
|
||||||
|
directive_reminder_tokens=self.directive_reminder_tokens,
|
||||||
|
directive_reminder_text=self.directive_reminder_text,
|
||||||
|
reminder_last_band=self.reminder_last_band,
|
||||||
|
on_reminder_band=self._on_reminder_band,
|
||||||
):
|
):
|
||||||
if isinstance(event, UsageEvent):
|
if isinstance(event, UsageEvent):
|
||||||
turn_rounds += 1
|
turn_rounds += 1
|
||||||
last_request = event.usage
|
last_request = event.usage
|
||||||
turn_usage = turn_usage.add(event.usage)
|
turn_usage = turn_usage.add(event.usage)
|
||||||
self.session_usage = self.session_usage.add(event.usage)
|
self.session_usage = self.session_usage.add(event.usage)
|
||||||
|
self.last_request_usage = event.usage
|
||||||
|
self.peak_prompt_tokens = max(
|
||||||
|
self.peak_prompt_tokens,
|
||||||
|
event.usage.prompt_tokens,
|
||||||
|
)
|
||||||
|
await self._persist_context_usage()
|
||||||
# After a full tool batch, commit prefix so failures do not
|
# After a full tool batch, commit prefix so failures do not
|
||||||
# discard work that already had external effects.
|
# discard work that already had external effects. Trailing
|
||||||
|
# developer checkpoints after that batch are committed too.
|
||||||
commit_end = committed_prefix_end(self.messages, user_index)
|
commit_end = committed_prefix_end(self.messages, user_index)
|
||||||
if commit_end > self._persist_from:
|
end = self._developer_tail_end(commit_end)
|
||||||
await self._persist_range(self._persist_from, commit_end)
|
if end > self._persist_from:
|
||||||
|
await self._persist_range(self._persist_from, end)
|
||||||
yield event
|
yield event
|
||||||
completed = True
|
completed = True
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -363,6 +459,7 @@ class ChatAgent:
|
|||||||
self.last_turn_rounds = turn_rounds
|
self.last_turn_rounds = turn_rounds
|
||||||
if self._persist_from < len(self.messages):
|
if self._persist_from < len(self.messages):
|
||||||
await self._persist_range(self._persist_from, len(self.messages))
|
await self._persist_range(self._persist_from, len(self.messages))
|
||||||
|
await self._persist_context_usage()
|
||||||
|
|
||||||
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
|
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
|
||||||
"""Append a user message (persist immediately), run the tool loop, yield events."""
|
"""Append a user message (persist immediately), run the tool loop, yield events."""
|
||||||
@@ -461,6 +558,7 @@ class ChatAgent:
|
|||||||
max_context_tokens=self.max_context_tokens,
|
max_context_tokens=self.max_context_tokens,
|
||||||
todo_stack=None,
|
todo_stack=None,
|
||||||
todo_nag_strategy="none",
|
todo_nag_strategy="none",
|
||||||
|
directive_reminder_tokens=0,
|
||||||
messages=history,
|
messages=history,
|
||||||
)
|
)
|
||||||
async for event in aside.run(text):
|
async for event in aside.run(text):
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Append-only developer playbook checkpoints at token-band crossings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
|
# 0 = disabled. Default matches long-session soft drift without spamming short chats.
|
||||||
|
DEFAULT_DIRECTIVE_REMINDER_TOKENS = 100_000
|
||||||
|
|
||||||
|
DEFAULT_DIRECTIVE_REMINDER_TEXT = """\
|
||||||
|
Tool playbook still applies (see system). Hard constraints:
|
||||||
|
- Prefer file tools over shell; several `run_command` calls may run in parallel; \
|
||||||
|
use `run_command_batch` for ordered pipelines.
|
||||||
|
- `edit_replace`: fix match / `max_replaces`; `read_file` with_lineno before `edit_lineno`.
|
||||||
|
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
|
||||||
|
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
|
||||||
|
- Todo stack: open items mean unfinished work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BAND_MARKER = re.compile(
|
||||||
|
r"\[DIRECTIVE CHECKPOINT band=(\d+)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def checkpoint_body(
|
||||||
|
band: int,
|
||||||
|
*,
|
||||||
|
tokens: int,
|
||||||
|
source: str,
|
||||||
|
reminder_text: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a durable developer checkpoint message body for *band*."""
|
||||||
|
playbook = (reminder_text if reminder_text is not None else DEFAULT_DIRECTIVE_REMINDER_TEXT).strip()
|
||||||
|
header = f"[DIRECTIVE CHECKPOINT band={band} tokens≈{tokens} source={source}]"
|
||||||
|
if not playbook:
|
||||||
|
return header
|
||||||
|
return f"{header}\n{playbook}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_checkpoint_bands(messages: Sequence[AnyChatMessage]) -> int:
|
||||||
|
"""Return the highest checkpoint band found in durable history (0 if none)."""
|
||||||
|
highest = 0
|
||||||
|
for msg in messages:
|
||||||
|
if not isinstance(msg, DeveloperChatMessage):
|
||||||
|
continue
|
||||||
|
match = _BAND_MARKER.search(msg.content)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
highest = max(highest, int(match.group(1)))
|
||||||
|
return highest
|
||||||
|
|
||||||
|
|
||||||
|
def bands_to_fire(*, last_fired_band: int, current_band: int) -> list[int]:
|
||||||
|
"""Inclusive bands to append so markers stay monotonic (fill gaps)."""
|
||||||
|
if current_band <= last_fired_band:
|
||||||
|
return []
|
||||||
|
return list(range(last_fired_band + 1, current_band + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def token_band(prompt_tokens: int, interval: int) -> int:
|
||||||
|
"""Band index for *prompt_tokens* (0 = below first threshold)."""
|
||||||
|
if interval < 1 or prompt_tokens < 1:
|
||||||
|
return 0
|
||||||
|
return prompt_tokens // interval
|
||||||
|
|
||||||
|
|
||||||
|
def inject_directive_checkpoints(
|
||||||
|
messages: list[AnyChatMessage],
|
||||||
|
*,
|
||||||
|
prompt_tokens: int,
|
||||||
|
source: str,
|
||||||
|
interval: int,
|
||||||
|
last_fired_band: int,
|
||||||
|
reminder_text: str | None = None,
|
||||||
|
) -> tuple[int, list[DeveloperChatMessage]]:
|
||||||
|
"""Append developer checkpoints for newly crossed bands.
|
||||||
|
|
||||||
|
Returns ``(new_last_fired_band, appended_messages)``. Does nothing when
|
||||||
|
*interval* < 1 or no new bands are crossed. Append-only (never edits prior
|
||||||
|
checkpoints) so prefix caching can keep a stable history prefix.
|
||||||
|
"""
|
||||||
|
if interval < 1:
|
||||||
|
return last_fired_band, []
|
||||||
|
current = token_band(prompt_tokens, interval)
|
||||||
|
to_fire = bands_to_fire(last_fired_band=last_fired_band, current_band=current)
|
||||||
|
if not to_fire:
|
||||||
|
return last_fired_band, []
|
||||||
|
appended: list[DeveloperChatMessage] = []
|
||||||
|
for band in to_fire:
|
||||||
|
msg = DeveloperChatMessage(
|
||||||
|
content=checkpoint_body(
|
||||||
|
band,
|
||||||
|
tokens=prompt_tokens,
|
||||||
|
source=source,
|
||||||
|
reminder_text=reminder_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messages.append(msg)
|
||||||
|
appended.append(msg)
|
||||||
|
return to_fire[-1], appended
|
||||||
+72
-10
@@ -26,6 +26,10 @@ from .budget import (
|
|||||||
estimate_messages_tokens,
|
estimate_messages_tokens,
|
||||||
truncate_tool_result,
|
truncate_tool_result,
|
||||||
)
|
)
|
||||||
|
from .directive_checkpoint import (
|
||||||
|
DEFAULT_DIRECTIVE_REMINDER_TOKENS,
|
||||||
|
inject_directive_checkpoints,
|
||||||
|
)
|
||||||
from .events import (
|
from .events import (
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
AssistantMessageEvent,
|
AssistantMessageEvent,
|
||||||
@@ -312,7 +316,36 @@ def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantCh
|
|||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
async def _maybe_inject_directive_checkpoints(
|
||||||
|
messages: list[AnyChatMessage],
|
||||||
|
*,
|
||||||
|
usage_event: UsageEvent | None,
|
||||||
|
interval: int,
|
||||||
|
last_band: int,
|
||||||
|
reminder_text: str | None,
|
||||||
|
on_reminder_band: Callable[[int], Awaitable[None] | None] | None,
|
||||||
|
) -> int:
|
||||||
|
"""Append durable checkpoints after a usage sample; return updated last band."""
|
||||||
|
if usage_event is None or interval < 1:
|
||||||
|
return last_band
|
||||||
|
new_band, appended = inject_directive_checkpoints(
|
||||||
|
messages,
|
||||||
|
prompt_tokens=usage_event.usage.prompt_tokens,
|
||||||
|
source=usage_event.usage.source,
|
||||||
|
interval=interval,
|
||||||
|
last_fired_band=last_band,
|
||||||
|
reminder_text=reminder_text,
|
||||||
|
)
|
||||||
|
if not appended:
|
||||||
|
return last_band
|
||||||
|
if on_reminder_band is not None:
|
||||||
|
maybe = on_reminder_band(new_band)
|
||||||
|
if inspect.isawaitable(maybe):
|
||||||
|
await maybe
|
||||||
|
return new_band
|
||||||
|
|
||||||
|
|
||||||
|
async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
|
||||||
client: ChatClient,
|
client: ChatClient,
|
||||||
messages: list[AnyChatMessage],
|
messages: list[AnyChatMessage],
|
||||||
*,
|
*,
|
||||||
@@ -327,6 +360,10 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
|||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
todo_stack: TodoStack | None = None,
|
todo_stack: TodoStack | None = None,
|
||||||
todo_nag_strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
todo_nag_strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
||||||
|
directive_reminder_tokens: int = DEFAULT_DIRECTIVE_REMINDER_TOKENS,
|
||||||
|
directive_reminder_text: str | None = None,
|
||||||
|
reminder_last_band: int = 0,
|
||||||
|
on_reminder_band: Callable[[int], Awaitable[None] | None] | None = None,
|
||||||
) -> AsyncIterator[AgentEvent]:
|
) -> AsyncIterator[AgentEvent]:
|
||||||
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
||||||
|
|
||||||
@@ -338,6 +375,11 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
|||||||
When *todo_stack* is set and still needs review after a natural stop
|
When *todo_stack* is set and still needs review after a natural stop
|
||||||
(open items, or non-empty stack untouched this turn), injects a review nag
|
(open items, or non-empty stack untouched this turn), injects a review nag
|
||||||
(channel from *todo_nag_strategy*) once so the model reconciles unfinished work.
|
(channel from *todo_nag_strategy*) once so the model reconciles unfinished work.
|
||||||
|
|
||||||
|
After each usage sample, may append durable developer directive checkpoints
|
||||||
|
when *prompt_tokens* crosses bands of *directive_reminder_tokens* (0 disables).
|
||||||
|
*reminder_last_band* is the highest band already injected (history/DB).
|
||||||
|
*on_reminder_band* is notified with the new last band after appends.
|
||||||
"""
|
"""
|
||||||
tool_items: Sequence[AnyToolItem] | None = None
|
tool_items: Sequence[AnyToolItem] | None = None
|
||||||
if tools is not None and len(tools) > 0:
|
if tools is not None and len(tools) > 0:
|
||||||
@@ -349,6 +391,7 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
|||||||
prompt_tokens_hint: int | None = None
|
prompt_tokens_hint: int | None = None
|
||||||
sent_estimate_tokens: int | None = None
|
sent_estimate_tokens: int | None = None
|
||||||
todo_review_injected = False
|
todo_review_injected = False
|
||||||
|
last_band = max(0, reminder_last_band)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
while rounds_used < allowance:
|
while rounds_used < allowance:
|
||||||
@@ -372,15 +415,42 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
|||||||
)
|
)
|
||||||
|
|
||||||
pre_len = len(messages)
|
pre_len = len(messages)
|
||||||
|
last_usage_event: UsageEvent | None = None
|
||||||
async for event in _assistant_round(client, param, messages, stream=stream):
|
async for event in _assistant_round(client, param, messages, stream=stream):
|
||||||
if isinstance(event, UsageEvent):
|
if isinstance(event, UsageEvent):
|
||||||
# Next rounds scale char-estimates by real/resolved prompt size.
|
# Next rounds scale char-estimates by real/resolved prompt size.
|
||||||
prompt_tokens_hint = event.usage.prompt_tokens
|
prompt_tokens_hint = event.usage.prompt_tokens
|
||||||
sent_estimate_tokens = sent_est
|
sent_estimate_tokens = sent_est
|
||||||
|
last_usage_event = event
|
||||||
yield event
|
yield event
|
||||||
|
|
||||||
assistant = _last_assistant(messages, pre_len)
|
assistant = _last_assistant(messages, pre_len)
|
||||||
tool_calls = assistant.tool_calls
|
tool_calls = assistant.tool_calls
|
||||||
if tool_calls is UNSET or not tool_calls or tools is None:
|
has_tools = tool_calls is not UNSET and bool(tool_calls) and tools is not None
|
||||||
|
if has_tools:
|
||||||
|
assert tools is not None
|
||||||
|
assert tool_calls is not UNSET
|
||||||
|
async for event in _execute_tool_calls(
|
||||||
|
tools,
|
||||||
|
tool_calls,
|
||||||
|
messages,
|
||||||
|
max_result_chars=max_tool_result_chars,
|
||||||
|
parallel=parallel_tools,
|
||||||
|
):
|
||||||
|
yield event
|
||||||
|
|
||||||
|
# After tools (or text-only assistant): durable checkpoints stay out of
|
||||||
|
# assistant→tool batches so commit/retry structure remains valid.
|
||||||
|
last_band = await _maybe_inject_directive_checkpoints(
|
||||||
|
messages,
|
||||||
|
usage_event=last_usage_event,
|
||||||
|
interval=directive_reminder_tokens,
|
||||||
|
last_band=last_band,
|
||||||
|
reminder_text=directive_reminder_text,
|
||||||
|
on_reminder_band=on_reminder_band,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_tools:
|
||||||
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
|
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
|
||||||
todo_review_injected = True
|
todo_review_injected = True
|
||||||
injected, nag_events = inject_todo_nag_for_stack_with_events(
|
injected, nag_events = inject_todo_nag_for_stack_with_events(
|
||||||
@@ -394,14 +464,6 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
|||||||
if injected:
|
if injected:
|
||||||
continue
|
continue
|
||||||
return
|
return
|
||||||
async for event in _execute_tool_calls(
|
|
||||||
tools,
|
|
||||||
tool_calls,
|
|
||||||
messages,
|
|
||||||
max_result_chars=max_tool_result_chars,
|
|
||||||
parallel=parallel_tools,
|
|
||||||
):
|
|
||||||
yield event
|
|
||||||
|
|
||||||
reason = f"tool loop reached {allowance} rounds (used {rounds_used})"
|
reason = f"tool loop reached {allowance} rounds (used {rounds_used})"
|
||||||
if on_limit is not None and await _call_on_limit(on_limit, reason):
|
if on_limit is not None and await _call_on_limit(on_limit, reason):
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Directive checkpoint bands and append-only inject."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plyngent.agent.directive_checkpoint import (
|
||||||
|
bands_to_fire,
|
||||||
|
inject_directive_checkpoints,
|
||||||
|
parse_checkpoint_bands,
|
||||||
|
token_band,
|
||||||
|
)
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AnyChatMessage,
|
||||||
|
DeveloperChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_band() -> None:
|
||||||
|
assert token_band(0, 100_000) == 0
|
||||||
|
assert token_band(99_999, 100_000) == 0
|
||||||
|
assert token_band(100_000, 100_000) == 1
|
||||||
|
assert token_band(250_000, 100_000) == 2
|
||||||
|
assert token_band(100_000, 0) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bands_to_fire_fill_gaps() -> None:
|
||||||
|
assert bands_to_fire(last_fired_band=0, current_band=0) == []
|
||||||
|
assert bands_to_fire(last_fired_band=0, current_band=1) == [1]
|
||||||
|
assert bands_to_fire(last_fired_band=0, current_band=2) == [1, 2]
|
||||||
|
assert bands_to_fire(last_fired_band=2, current_band=2) == []
|
||||||
|
assert bands_to_fire(last_fired_band=1, current_band=3) == [2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_append_only_and_parse() -> None:
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
band, appended = inject_directive_checkpoints(
|
||||||
|
messages,
|
||||||
|
prompt_tokens=100_000,
|
||||||
|
source="api",
|
||||||
|
interval=100_000,
|
||||||
|
last_fired_band=0,
|
||||||
|
)
|
||||||
|
assert band == 1
|
||||||
|
assert len(appended) == 1
|
||||||
|
assert isinstance(messages[-1], DeveloperChatMessage)
|
||||||
|
assert "band=1" in messages[-1].content
|
||||||
|
assert parse_checkpoint_bands(messages) == 1
|
||||||
|
|
||||||
|
band2, appended2 = inject_directive_checkpoints(
|
||||||
|
messages,
|
||||||
|
prompt_tokens=250_000,
|
||||||
|
source="api",
|
||||||
|
interval=100_000,
|
||||||
|
last_fired_band=band,
|
||||||
|
)
|
||||||
|
assert band2 == 2
|
||||||
|
assert len(appended2) == 1
|
||||||
|
assert parse_checkpoint_bands(messages) == 2
|
||||||
|
|
||||||
|
# Same band: no re-fire
|
||||||
|
band3, appended3 = inject_directive_checkpoints(
|
||||||
|
messages,
|
||||||
|
prompt_tokens=250_000,
|
||||||
|
source="api",
|
||||||
|
interval=100_000,
|
||||||
|
last_fired_band=band2,
|
||||||
|
)
|
||||||
|
assert band3 == 2
|
||||||
|
assert appended3 == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_disabled() -> None:
|
||||||
|
messages: list[AnyChatMessage] = []
|
||||||
|
band, appended = inject_directive_checkpoints(
|
||||||
|
messages,
|
||||||
|
prompt_tokens=500_000,
|
||||||
|
source="api",
|
||||||
|
interval=0,
|
||||||
|
last_fired_band=0,
|
||||||
|
)
|
||||||
|
assert band == 0
|
||||||
|
assert appended == []
|
||||||
Reference in New Issue
Block a user