mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-24 05:47:32 +00:00
160 lines
6.8 KiB
Python
160 lines
6.8 KiB
Python
"""Propose recoverable tool-result previews without changing source history."""
|
|
|
|
import asyncio
|
|
from copy import deepcopy
|
|
from dataclasses import asdict, is_dataclass
|
|
import math
|
|
import time
|
|
|
|
from ..base_step import BaseStep, Ref
|
|
from ...components import R
|
|
from ...components.as_decision_model import BaseAsDecisionModel
|
|
from ...enumeration import ComponentEnum
|
|
from ...schema.compaction import CallDecision, CompactionRequest, CompactionResult
|
|
from .messages import collect_pairs, preview_result
|
|
from .state import build_batches, estimate_decision_tokens, serialize
|
|
|
|
|
|
def probability(answer) -> float | None:
|
|
"""Malformed or missing answers express no usable judgment."""
|
|
value = getattr(answer, "probability", None)
|
|
if (
|
|
getattr(answer, "type", None) == "binary_answer"
|
|
and not isinstance(value, bool)
|
|
and isinstance(value, (int, float))
|
|
and math.isfinite(value)
|
|
and 0 <= value <= 1
|
|
):
|
|
return float(value)
|
|
return None
|
|
|
|
|
|
@R.register("compact_context_step")
|
|
class CompactContextStep(BaseStep):
|
|
"""The host may inject ``token_counter(messages)`` to enforce its full input budget."""
|
|
|
|
as_decision_model = Ref(BaseAsDecisionModel, ComponentEnum.AS_DECISION_MODEL)
|
|
|
|
async def execute(self):
|
|
started = time.monotonic()
|
|
request = CompactionRequest.model_validate(
|
|
{key: self.context[key] for key in CompactionRequest.model_fields if key in self.context},
|
|
)
|
|
options = request.options
|
|
counter = self.kwargs.get("token_counter")
|
|
if options.max_output_tokens is not None and not callable(counter):
|
|
raise ValueError("max_output_tokens requires a host token_counter(messages).")
|
|
|
|
def count(messages):
|
|
if options.max_output_tokens is None:
|
|
return None
|
|
value = counter(messages)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
raise ValueError("token_counter must return a non-negative integer.")
|
|
return value
|
|
|
|
before = count(request.messages)
|
|
within_budget = before is not None and before <= options.max_output_tokens
|
|
pairs = collect_pairs(request)
|
|
batches = [] if within_budget else build_batches(request, pairs)
|
|
answers, failures, usage = {}, {}, []
|
|
semaphore = asyncio.Semaphore(options.max_concurrency)
|
|
|
|
async def ask(state, questions):
|
|
try:
|
|
async with semaphore:
|
|
response = await self.as_decision_model(state=state, questions=questions)
|
|
batch_answers = {name: probability(response.content.get(name)) for name in questions}
|
|
if response.usage is not None:
|
|
usage.append(asdict(response.usage) if is_dataclass(response.usage) else response.usage)
|
|
answers.update(batch_answers)
|
|
except Exception: # A provider failure must never become evidence for removal.
|
|
failures.update(dict.fromkeys(questions, "model_error"))
|
|
|
|
try:
|
|
async with asyncio.timeout(options.total_timeout_seconds):
|
|
async with asyncio.TaskGroup() as tasks:
|
|
for state, questions in batches:
|
|
tasks.create_task(ask(state, questions))
|
|
except TimeoutError:
|
|
for _, questions in batches:
|
|
failures.update({name: "timeout" for name in questions if name not in answers})
|
|
decisions = [
|
|
CallDecision(
|
|
id=pair.id,
|
|
call_id=pair.call_id,
|
|
action="keep",
|
|
reason=(
|
|
"protected"
|
|
if pair.protected
|
|
else (
|
|
"within_budget"
|
|
if within_budget
|
|
else failures.get(pair.id, "invalid_answer" if pair.id in answers else "unobserved")
|
|
)
|
|
),
|
|
probability=answers.get(pair.id),
|
|
source_ref=pair.source_ref,
|
|
)
|
|
for pair in pairs
|
|
]
|
|
messages, after = request.messages, before
|
|
ranked = sorted(
|
|
(d for d in decisions if d.probability is not None and d.probability <= options.drop_threshold),
|
|
key=lambda d: d.probability,
|
|
)
|
|
for decision in decisions:
|
|
if decision.probability is not None:
|
|
decision.reason = "needed_or_uncertain"
|
|
proposed = deepcopy(request.messages) if ranked else request.messages
|
|
by_id = {pair.id: pair for pair in pairs}
|
|
for decision in ranked:
|
|
if after is not None and after <= options.max_output_tokens:
|
|
decision.reason = "within_budget"
|
|
continue
|
|
pair = by_id[decision.id]
|
|
mi, bi = pair.result_location
|
|
block = proposed[mi]["content"][bi]
|
|
replacement = preview_result(block, pair.source_ref, options.preview_chars)
|
|
if replacement is None:
|
|
decision.reason = "no_savings"
|
|
continue
|
|
original_output, block["output"] = block["output"], replacement
|
|
proposed_tokens = count(proposed)
|
|
if after is not None and proposed_tokens >= after:
|
|
block["output"] = original_output
|
|
decision.reason = "no_savings"
|
|
continue
|
|
decision.action, decision.reason = "preview", "low_relevance"
|
|
messages, after = proposed, proposed_tokens
|
|
changed = messages != request.messages
|
|
budget_met = None if after is None else after <= options.max_output_tokens
|
|
stats = {
|
|
"messages_before": len(request.messages),
|
|
"messages_after": len(messages),
|
|
"chars_before": len(serialize(request.messages)),
|
|
"chars_after": len(serialize(messages)),
|
|
"tokens_before": before,
|
|
"tokens_after": after,
|
|
"candidates": sum(not pair.protected for pair in pairs),
|
|
"protected": sum(pair.protected for pair in pairs),
|
|
"state_tokens": max((estimate_decision_tokens(state) for state, _ in batches), default=0),
|
|
"requests": len(batches),
|
|
"usage": usage,
|
|
"elapsed_ms": round((time.monotonic() - started) * 1000),
|
|
}
|
|
result = CompactionResult(
|
|
messages=messages,
|
|
decisions=decisions,
|
|
changed=changed,
|
|
reason=(
|
|
"budget_exceeded"
|
|
if budget_met is False
|
|
else "compacted" if changed else "no_candidates" if not stats["candidates"] else "no_change"
|
|
),
|
|
budget_met=budget_met,
|
|
stats=stats,
|
|
)
|
|
self.context.response.answer = result.model_dump(mode="json")
|
|
self.context.response.metadata["compaction"] = stats
|
|
return self.context.response
|