Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions core/agent_runtime/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ class AgentRunSpec:
# follow-up prompt and the loop keeps going. ``stop_hook_active`` is passed
# so a well-behaved hook stops blocking after its first continuation.
stop_hook: Any | None = None
# P1-5 (GenAI lesson 15): compaction-as-memory. Called with the handoff
# summary + anchor metadata (session key, phase, timestamp, replaced
# message count) after a compaction successfully shrinks the history, so
# the host can deposit the summary into the memory vault — compressed
# sessions stay retrievable instead of vanishing. Must never raise; a
# failing sink is logged and swallowed.
compaction_summary_sink: Any | None = None

def allowed_tool_names(self) -> frozenset[str] | None:
if self.tool_filter is None:
Expand Down Expand Up @@ -1696,6 +1703,7 @@ async def _maybe_compact(
budget,
_COMPACT_TRIGGER_FRACTION,
)
self._notify_compaction_summary(spec, summary, messages, compacted, "auto")
return compacted

def _estimate_prompt(
Expand Down Expand Up @@ -1797,8 +1805,42 @@ async def compact_history(
"Compaction would not shrink the conversation. "
"The conversation is unchanged."
)
self._notify_compaction_summary(spec, summary, messages, compacted, "manual")
return compacted, "compacted"

def _notify_compaction_summary(
self,
spec: AgentRunSpec,
summary: str,
before: list[dict[str, Any]],
after: list[dict[str, Any]],
phase: str,
) -> None:
"""Deposit the handoff summary + anchors into the memory sink (P1-5).

Pure fire-and-forget: a failing or absent sink never affects the
compaction result. Anchors keep the summary retrievable and
attributable (lesson 15: compressed summaries must carry session id,
phase, and timestamps rather than vanishing into the vault).
"""
if spec.compaction_summary_sink is None:
return
import time as _time

anchor = {
"session_key": spec.session_key or "default",
"phase": phase,
"at": _time.strftime("%Y-%m-%dT%H:%M:%S"),
"messages_before": len(before),
"messages_after": len(after),
"chars_before": self._history_chars(before),
"chars_after": self._history_chars(after),
}
try:
spec.compaction_summary_sink(summary, anchor)
except Exception: # noqa: BLE001 - memory work must never break the turn
logger.debug("compaction summary sink failed", exc_info=True)

@staticmethod
def _build_compacted_history(
messages: list[dict[str, Any]], summary: str
Expand Down
48 changes: 48 additions & 0 deletions core/agent_runtime/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,54 @@
"object": dict,
}

# P1-2 (GenAI lesson 11): description quality bounds. The description is what
# the model routes on — it decides which tool to call and how well arguments
# are filled. Enforced at registration/schema time, never at runtime cost.
_DESCRIPTION_MAX_CHARS = 2_000 # lesson 11: definitions count against the prompt
_DESCRIPTION_MIN_CHARS = 20 # below this the description is nearly useless


def description_quality_issues(description: str) -> list[str]:
"""Quality checks on a tool description (empty list = pass).

Lesson 11's rule: a description must be *specific and clear*. This is the
cheap static proxy: bounded length (token budget), minimum substance
(not empty/tiny), and no verbatim JSON-dump noise that wastes tokens.
"""
issues: list[str] = []
text = str(description or "")
if not text.strip():
issues.append("description is empty")
elif len(text) < _DESCRIPTION_MIN_CHARS:
issues.append(
f"description is only {len(text)} chars; be more specific "
f"(min {_DESCRIPTION_MIN_CHARS})"
)
if len(text) > _DESCRIPTION_MAX_CHARS:
issues.append(
f"description is {len(text)} chars (max {_DESCRIPTION_MAX_CHARS}); "
"trim it — tool definitions count against the prompt budget"
)
return issues


def sanitize_description(description: str, *, name: str = "tool") -> str:
"""Bound + degenerate-fallback a description to the P1-2 contract.

Truncates over-long descriptions at a sentence boundary and replaces
unusable ones (empty or pure placeholder text) with the tool name so the
model still has *something* to route on — never an empty string.
"""
text = str(description or "").strip()
if len(text) <= _DESCRIPTION_MAX_CHARS:
return text or f"{name} tool (no description provided)"
# Truncate at the last sentence end within the cap.
cut = text[:_DESCRIPTION_MAX_CHARS]
boundary = max(cut.rfind(". "), cut.rfind(".\n"), cut.rfind("\n"))
if boundary > _DESCRIPTION_MIN_CHARS:
cut = cut[: boundary + 1]
return cut + " …[truncated]"


class ToolResult(str):
"""Model-visible tool text with frontend-safe execution metadata.
Expand Down
9 changes: 8 additions & 1 deletion core/agent_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@
"in_progress at a time), and keep it current as you go. After a write, "
"edit, or apply_patch, check the tool result for a 'Diagnostics detected' "
"block and fix any reported errors. When the task is done, reply with a "
"short summary."
"short summary.\n\n"
"Do not fabricate. When you lack evidence for a claim — a file's "
"existence or content, an API signature, a command's output, a tool "
"result, or a past decision — say so explicitly and gather the evidence "
"with the appropriate tool (read, glob, grep, bash) instead of inventing "
"it. If evidence cannot be obtained, state that it is unknown and ask for "
"the needed information rather than guessing. Never present an assumed "
"outcome as a verified one."
)


Expand Down
68 changes: 68 additions & 0 deletions core/events/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@

_DEFAULT_MAX_TOOL_RESULT_CHARS = 60_000

# P1-4 (GenAI course lesson 05): the tool loop + structured outputs run at a
# low temperature so repeated executions are reproducible (0.1 vs 0.9 variance
# is the lesson's canonical trap). Creative subtasks override explicitly via
# the execution profile / provider default. reasoning/effort models may ignore
# temperature — that is their documented behavior, not a wiring bug.
_DEFAULT_TOOL_LOOP_TEMPERATURE = 0.1


def _one_line_detail(value: str, *, limit: int = 80) -> str:
"""Bound a tool-declared presentation value without inspecting its data."""
Expand Down Expand Up @@ -456,6 +463,9 @@ def __init__(
# this to ask the model for one final complete/blocked/continue decision;
# ordinary Turns leave it unset.
self._closure_callback = closure_callback
# P1-5: compaction summaries → memory vault (compacted sessions stay
# retrievable). Built once here so auto and manual compaction share it.
self._compaction_summary_sink = self._make_compaction_summary_sink()
self._mcp_runtime = mcp_runtime
# Secret-free immutable selection used by persistence/frontends.
self.execution_profile = execution_profile
Expand Down Expand Up @@ -483,6 +493,48 @@ def _emit(self, msg) -> None:
)
)

def _make_compaction_summary_sink(self):
"""P1-5: build the compaction → memory deposit callable (never raises).

The sink runs the memory write on a daemon thread (non-blocking, like
memory distillation) so compaction never stalls the turn. Fires the
P1-3 canonical ``memory.compaction.deposited`` event on success.
"""

def _deposit(summary: str, anchor: dict[str, Any] | None = None) -> None:
import threading

def _work() -> None:
try:
from core.harness.memory import write_compaction_summary

write_compaction_summary(self._workspace, summary, anchor)
try:
from core.observability.events import emit_event

emit_event(
"memory.compaction.deposited",
session=(anchor or {}).get("session_key"),
chars=len(summary or ""),
phase=(anchor or {}).get("phase"),
)
except Exception: # noqa: BLE001, S110
pass
except Exception: # noqa: BLE001 - memory work never breaks turns
logger.debug("compaction summary deposit failed", exc_info=True)

try:
thread = threading.Thread(
target=_work,
name="compaction-memory",
daemon=True,
)
thread.start()
except Exception: # noqa: BLE001, S110
pass

return _deposit

async def next_event(self) -> Event:
return await self._events.get()

Expand Down Expand Up @@ -573,6 +625,7 @@ async def compact(self) -> dict[str, Any]:
max_iterations=1,
max_tool_result_chars=_DEFAULT_MAX_TOOL_RESULT_CHARS,
context_window_tokens=self._context_window_tokens,
compaction_summary_sink=self._compaction_summary_sink,
)
before = list(self._history)
compacted, reason = await self._runner.compact_history(spec, before)
Expand Down Expand Up @@ -911,12 +964,26 @@ def visible_tool_names() -> tuple[str, ...] | None:
names = tuple(str(name) for name in value)
return names

# P1-4: default low temperature for the tool loop (reproducible
# executions); an explicit execution-profile temperature (creative
# subtasks) wins. 0.0 is a legitimate explicit choice, so compare
# against None rather than truthiness.
_profile_temperature = (
getattr(self.execution_profile, "temperature", None)
if self.execution_profile is not None
else None
)
spec = AgentRunSpec(
initial_messages=initial,
tools=self._tools,
model=self._model,
max_iterations=self._max_iterations,
max_tool_result_chars=_DEFAULT_MAX_TOOL_RESULT_CHARS,
temperature=(
_profile_temperature
if _profile_temperature is not None
else _DEFAULT_TOOL_LOOP_TEMPERATURE
),
transient_context_messages=tuple(turn_context_messages),
workspace=self._workspace,
context_window_tokens=self._context_window_tokens,
Expand All @@ -936,6 +1003,7 @@ def visible_tool_names() -> tuple[str, ...] | None:
if self._skill_runtime is not None or self._tool_filter is not None
else None
),
compaction_summary_sink=self._compaction_summary_sink,
)

try:
Expand Down
Loading
Loading