Nan Gao 7389331e65
feat(extensions): observe task lifecycle and system model calls (#4684)
* feat(extensions): observe task lifecycle and system model calls

PR 1 (#4636) gave extensions a middleware chain, and a middleware only sees
what passes through the agent graph. Two runtime surfaces stay invisible to
it: when a lead run or a subagent begins and ends, and the DeerFlow-owned
model calls made outside the graph. This slice adds both, with no new
Gateway surface -- routers, services, and the reference extension stay in
PR 3.

Contract (deerflow-extension-api 0.1.1)
---------------------------------------
Two contribution kinds join `middlewares` on the registry:
`task_lifecycle` (`on_task_start` / `on_task_stop`, receiving a `TaskInfo`
and a conservative `TaskOutcome` of completed / aborted / failed) and
`system_model_observer` (`on_system_model_call`, receiving a
`SystemOperationKind`, a `SystemModelRequest` snapshot, and a
`SystemModelResult` carrying either the response or the provider exception
plus a duration).

`SystemModelRequest.messages` normalizes to a tuple at construction. Goal
evaluation and memory extraction pass a message list while title generation
and summarization pass one prompt string, and a bare `str` already satisfies
`Sequence` -- without normalization an observer iterating `request.messages`
would silently walk characters. Copying also makes the frozen snapshot
immutable in fact rather than only by declaration, since observations may run
after the call site returns and keeps mutating its own list.

Registry marks and rollbacks become per-bucket and positional, so an
`install()` that fails after registering two different kinds cannot leave one
of them behind. `needs_task_store` now covers all three kinds: a deployment
that registers only lifecycle hooks still gets a task store.

Task lifecycle
--------------
The lead worker notifies start after the run has started and stop after
completion persistence and the completion hook, but before clearing the
finalizing barrier and publishing the stream end -- holding the barrier
across stop is what keeps a same-thread replacement run from overlapping this
task's lifecycle. Cancellation raised out of the stop notification is
deferred, not propagated in place, so a cancelled run still clears the
barrier and emits its end frame. A subagent with a parent `run_id` wraps its
execution in the same pair inside `finally`, reporting `parent_task_id` so a
delegation tree is reconstructable; a subagent without a `run_id` (embedded
client, standalone LangGraph Server) logs and skips rather than inventing a
parent. Contributors run in registration order inside one shared 3s budget
and every failure is logged and failed open.

System model calls
------------------
Four kinds cover the model calls the middleware chain cannot see: goal
evaluation, memory extraction, title generation, and summarization. Each site
reports both terminal paths without changing the provider exception the host
observes, short-circuits on `has_system_model_observers`, and passes the live
task store when the runtime has one (detached work gets an isolated store).
The sync summarization half stays unobserved on purpose -- it and its only
host caller are the sync side of an async-only runtime, so notifying there
would block a thread on a call site the host never reaches; the reason is
recorded at the call site.

The DeerMem backend must stay vendorable and cannot import the extension API,
so it reports through a new `MemoryCallbacks.on_memory_llm_result` host hook
that the DeerFlow-side callbacks translate into an observation.

Notification loop
-----------------
Extension resources must be touched on the loop that created them, but
subagents can execute on isolated loops and DeerMem runs on a worker thread.
The Gateway registers its serving loop before any runtime dependency starts
and resets it last through the exit stack, so every startup-failure and
cancellation path is covered. Awaited hooks raised on another loop are
dispatched across with `run_coroutine_threadsafe` and awaited under the same
budget; synchronous sites submit fire-and-forget work. Shutdown stops
accepting detached observations before the memory flush -- that flush runs on
a worker thread and can emit memory observations -- while keeping the loop
alive for awaited task hooks until run and subagent drain completes.

Tests
-----
`test_extension_task_lifecycle.py`, `test_extension_subagent_lifecycle.py`,
and `test_extension_system_model_calls.py` cover ordering, fail-open, budget
exhaustion, snapshot binding under a concurrent singleton replacement, the
loop-dispatch and shutdown-suspension paths, and both terminal paths at every
call site. `test_gateway_run_drain_shutdown.py` pins the stop-before-barrier
and drain ordering.

* fix(extensions): decide notification fail-open by origin, observe cancellation

`_notify_each` only guarded `Exception`, so a contributor letting a
`CancelledError` escape — an extension implementing an internal timeout with
cancellation, say — skipped its successors and reached the worker's
deferred-interrupt path, ending an otherwise successful run as cancelled.
Fail-open is about where a failure came from, not its base class: only a
genuine cancellation of the host task increments `Task.cancelling()`, so
propagate on that and contain everything else. `KeyboardInterrupt` /
`SystemExit` still propagate.

`observe_system_model_call` skipped observers on cancellation for the same
base-class reason, leaving goal / title / summarization silent on a terminal
path that is routine — interrupt/rollback admission and shutdown both cancel
the run task, with the provider tokens already spent. Awaiting observers there
is unreliable (a repeated cancel interrupts that await before any of them
runs), so report through the same non-blocking submission the synchronous
memory bridge uses, then propagate the cancellation untouched.

DeerMem keeps `BaseException` around its provider call, now with the reason
recorded: that path runs on a worker thread, where cancelling the awaiting
side never interrupts the running thread, so `CancelledError` cannot arrive
at all. Its host-hook wrapper narrows to `Exception` — only the hook's own
failures are non-fatal, and an observability path must not swallow a process
teardown signal.

* fix(extensions): warn on budget exhaustion, scope observer logs by task, propagate teardown

Review response on #4684:

- The memory observation bridge caught BaseException, which would swallow
  a teardown signal raised while dispatching; it now catches Exception,
  matching the boundary the DeerMem-side call site documents and tests.
- A notification-budget timeout raised mid-hook fell into the generic
  hook-failure path and logged an asyncio-internal traceback; it now logs
  a warning like the pre-hook budget skip, while a TimeoutError a
  contributor raises on its own stays classified as a hook failure.
- System model observer logs passed the operation kind as the task id,
  so log lines said "task goal/title/..."; they now carry the task
  scope id alongside the kind.
2026-08-11 16:33:22 +08:00

586 lines
23 KiB
Python

"""Thread-scoped goal state and evaluator helpers.
This module implements the Claude Code-style goal loop primitives used by
Gateway runs and thin API surfaces. It intentionally lives in ``deerflow`` so
the harness can evaluate and continue runs without importing the FastAPI app.
"""
from __future__ import annotations
import asyncio
import copy
import hashlib
import inspect
import json
import logging
import os
import threading
import weakref
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any, Literal, NamedTuple
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.checkpoint.base import empty_checkpoint, uuid6
import deerflow.utils.llm_text as llm_text
from deerflow.agents.goal_state import GoalBlocker, GoalEvaluation, GoalState
from deerflow.models import create_chat_model
from deerflow.tracing import inject_langfuse_metadata
from deerflow.utils.messages import message_to_text
from deerflow.utils.time import now_iso
logger = logging.getLogger(__name__)
DEFAULT_MAX_GOAL_CONTINUATIONS = 8
DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS = 2
MAX_GOAL_OBJECTIVE_CHARS = 4000
MAX_GOAL_REASON_CHARS = 1000
MAX_GOAL_EVIDENCE_CHARS = 1000
MAX_GOAL_CONVERSATION_CHARS = 12000
MAX_GOAL_CONVERSATION_MESSAGES = 30
GOAL_BLOCKERS: set[GoalBlocker] = {
"none",
"missing_evidence",
"needs_user_input",
"run_failed",
"external_wait",
"goal_not_met_yet",
}
CONTINUABLE_GOAL_BLOCKERS: set[GoalBlocker] = {"goal_not_met_yet"}
GOAL_CLEAR_ALIASES = frozenset({"clear", "reset", "off"})
_extract_response_text = llm_text.extract_response_text
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence
_strip_think_blocks = llm_text.strip_think_blocks
_goal_locks_guard = threading.Lock()
_goal_locks_by_loop: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = weakref.WeakKeyDictionary()
class GoalWriteConflict(RuntimeError):
"""Raised when a goal write is based on a stale checkpoint."""
@asynccontextmanager
async def goal_thread_lock(thread_id: str) -> AsyncIterator[None]:
"""Serialize goal read-modify-write sequences within the current event loop."""
loop = asyncio.get_running_loop()
with _goal_locks_guard:
locks = _goal_locks_by_loop.get(loop)
if locks is None:
locks = {}
_goal_locks_by_loop[loop] = locks
lock = locks.get(thread_id)
if lock is None:
lock = asyncio.Lock()
locks[thread_id] = lock
async with lock:
yield
class GoalCommand(NamedTuple):
"""Parsed intent of a ``/goal`` slash command argument string."""
kind: Literal["status", "clear", "set"]
objective: str = ""
def parse_goal_command(args: str) -> GoalCommand:
"""Parse the argument string of a ``/goal`` command into an intent.
Shared by the TUI and IM-channel surfaces so the three-way semantics stay in
one place: empty shows the active goal, ``clear``/``reset``/``off`` clears it,
and anything else sets the goal to that (trimmed) objective. The frontend
keeps a parallel TypeScript copy in ``input-box-helpers.ts``.
"""
stripped = args.strip()
if not stripped:
return GoalCommand("status")
if stripped.lower() in GOAL_CLEAR_ALIASES:
return GoalCommand("clear")
return GoalCommand("set", stripped)
def normalize_goal_objective(objective: str) -> str:
"""Normalize and validate user-provided goal text."""
normalized = " ".join(objective.strip().split())
if not normalized:
raise ValueError("Goal objective must not be empty.")
if len(normalized) > MAX_GOAL_OBJECTIVE_CHARS:
raise ValueError(f"Goal objective must be at most {MAX_GOAL_OBJECTIVE_CHARS} characters.")
return normalized
def build_goal_state(
objective: str,
*,
max_continuations: int = DEFAULT_MAX_GOAL_CONTINUATIONS,
max_no_progress_continuations: int = DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS,
now: str | None = None,
) -> GoalState:
"""Create a fresh active goal state for a thread."""
objective = normalize_goal_objective(objective)
capped_max = max(0, min(int(max_continuations), DEFAULT_MAX_GOAL_CONTINUATIONS))
timestamp = now or now_iso()
return GoalState(
objective=objective,
status="active",
created_at=timestamp,
updated_at=timestamp,
continuation_count=0,
max_continuations=capped_max,
no_progress_count=0,
max_no_progress_continuations=max(0, int(max_no_progress_continuations)),
)
def parse_goal_evaluation_response(text: str) -> GoalEvaluation:
"""Parse the evaluator's JSON object response."""
candidate = _strip_markdown_code_fence(_strip_think_blocks(text))
start = candidate.find("{")
end = candidate.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("Goal evaluator response did not contain a JSON object.")
try:
payload = json.loads(candidate[start : end + 1])
except Exception as exc:
raise ValueError("Goal evaluator response was not valid JSON.") from exc
if not isinstance(payload, dict):
raise ValueError("Goal evaluator JSON must be an object.")
satisfied = payload.get("satisfied")
if not isinstance(satisfied, bool):
raise ValueError("Goal evaluator JSON must include boolean 'satisfied'.")
reason = _normalize_evaluation_text(payload.get("reason"), max_chars=MAX_GOAL_REASON_CHARS)
evidence_summary = _normalize_evaluation_text(payload.get("evidence_summary"), max_chars=MAX_GOAL_EVIDENCE_CHARS)
blocker = _normalize_goal_blocker(payload.get("blocker"), satisfied=satisfied)
return GoalEvaluation(
satisfied=satisfied,
blocker=blocker,
reason=reason,
evidence_summary=evidence_summary,
)
def _normalize_evaluation_text(value: object, *, max_chars: int) -> str:
if not isinstance(value, str):
return ""
return " ".join(value.strip().split())[:max_chars]
def _normalize_goal_blocker(value: object, *, satisfied: bool) -> GoalBlocker:
if satisfied:
return "none"
if isinstance(value, str) and value in GOAL_BLOCKERS and value != "none":
return value
return "missing_evidence"
def _message_type(message: Any) -> str | None:
value = getattr(message, "type", None)
if value is None and isinstance(message, dict):
value = message.get("type") or message.get("role")
if value == "assistant":
return "ai"
if value == "user":
return "human"
return str(value) if value else None
def _additional_kwargs(message: Any) -> dict[str, Any]:
value = getattr(message, "additional_kwargs", None)
if value is None and isinstance(message, dict):
value = message.get("additional_kwargs")
return dict(value) if isinstance(value, dict) else {}
def _is_visible_message(message: Any) -> bool:
if _additional_kwargs(message).get("hide_from_ui") is True:
return False
return _message_type(message) in {"human", "ai"}
def has_visible_assistant_evidence(messages: list[Any]) -> bool:
"""Return true when the evaluator can inspect at least one visible AI reply."""
return any(_is_visible_message(message) and _message_type(message) == "ai" and bool(message_to_text(message).strip()) for message in messages)
def visible_conversation_signature(messages: list[Any]) -> str:
"""Return a stable lightweight signature for the visible evaluator evidence."""
visible = []
for message in messages:
if not _is_visible_message(message):
continue
visible.append(
{
"role": _message_type(message),
"text": message_to_text(message).strip(),
}
)
return json.dumps(visible[-MAX_GOAL_CONVERSATION_MESSAGES:], ensure_ascii=False, sort_keys=True)
def format_visible_conversation(messages: list[Any]) -> str:
"""Return the user-visible conversation evidence for goal evaluation."""
lines: list[str] = []
visible = [message for message in messages if _is_visible_message(message)]
for message in visible[-MAX_GOAL_CONVERSATION_MESSAGES:]:
text = message_to_text(message).strip()
if not text:
continue
role = "User" if _message_type(message) == "human" else "Assistant"
lines.append(f"{role}: {text}")
conversation = "\n\n".join(lines)
if len(conversation) > MAX_GOAL_CONVERSATION_CHARS:
conversation = conversation[-MAX_GOAL_CONVERSATION_CHARS:]
return conversation
def create_goal_evaluator_model(
*,
model_name: str | None = None,
app_config: Any | None = None,
) -> Any:
"""Create the non-thinking chat model used by the goal evaluator.
The evaluator runs from ``runtime/runs/worker.py`` after the main graph
run has already completed, so — unlike ``make_lead_agent``/
``DeerFlowClient.stream``, which attach ``build_tracing_callbacks()`` at
the graph root and correctly pass ``attach_tracing=False`` to avoid
double-attaching — there is no graph root here for the evaluator's model
call to inherit tracing from. It must attach its own model-level tracing
callbacks, same as the other standalone, non-graph callers
(``oneshot_llm.run_oneshot_llm``, ``MemoryUpdater``).
"""
return create_chat_model(
name=model_name,
thinking_enabled=False,
app_config=app_config,
attach_tracing=True,
)
def _resolve_environment() -> str | None:
return os.environ.get("DEER_FLOW_ENV") or os.environ.get("ENVIRONMENT")
async def evaluate_goal_completion(
goal: GoalState,
messages: list[Any],
*,
model: Any | None = None,
model_name: str | None = None,
app_config: Any | None = None,
thread_id: str | None = None,
user_id: str | None = None,
deerflow_trace_id: str | None = None,
task_store: Any | None = None,
extensions: Any | None = None,
) -> GoalEvaluation:
"""Ask a small non-thinking model whether the active goal is satisfied.
``thread_id``/``user_id``/``deerflow_trace_id`` are forwarded to Langfuse
trace metadata only (mirrors ``oneshot_llm.run_oneshot_llm``): this is a
standalone model call outside the main graph, so it must inject its own
Langfuse session/user attribution instead of relying on graph-root
callbacks to lift it — same fix as PR #2944 (main graph) and PR #3902
(memory_agent/suggest_agent).
"""
conversation = format_visible_conversation(messages)
if not conversation or not has_visible_assistant_evidence(messages):
return GoalEvaluation(
satisfied=False,
blocker="missing_evidence",
reason="No visible assistant evidence is available yet.",
evidence_summary="",
)
system_instruction = (
"You are a strict completion evaluator for an AI coding assistant.\n"
"Decide whether the active goal is fully satisfied using ONLY the visible conversation evidence.\n"
"Do not assume files, commands, tests, or external state changed unless the conversation explicitly shows it.\n"
"If the visible evidence is too weak to prove progress, fail closed with blocker missing_evidence.\n"
"Use blocker needs_user_input when the assistant is waiting on the user, run_failed when the turn failed, "
"external_wait when work is waiting on an outside system, goal_not_met_yet when useful autonomous work can continue, "
"and none only when satisfied is true.\n"
'Output exactly one JSON object: {"satisfied": boolean, "blocker": string, "reason": string, "evidence_summary": string}.'
)
user_content = f"Active goal:\n{goal['objective']}\n\nVisible conversation evidence:\n{conversation}\n\nIs the active goal fully satisfied?"
if model is None:
model = create_goal_evaluator_model(model_name=model_name, app_config=app_config)
invoke_config: dict[str, Any] = {"run_name": "goal_evaluator"}
inject_langfuse_metadata(
invoke_config,
thread_id=thread_id,
user_id=user_id,
assistant_id="goal_evaluator",
model_name=model_name,
environment=_resolve_environment(),
deerflow_trace_id=deerflow_trace_id,
)
prompt_messages = [
SystemMessage(content=system_instruction),
HumanMessage(content=user_content),
]
if extensions is None:
response = await model.ainvoke(prompt_messages, config=invoke_config)
else:
from deerflow_extension_api import SystemOperationKind
from deerflow.extensions.notify import observe_system_model_call
response = await observe_system_model_call(
extensions,
SystemOperationKind.GOAL,
messages=prompt_messages,
model_name=model_name,
invoke_config=invoke_config,
invoke=lambda: model.ainvoke(prompt_messages, config=invoke_config),
task_store=task_store,
)
return parse_goal_evaluation_response(_extract_response_text(response.content))
def should_continue_goal(goal: GoalState, evaluation: GoalEvaluation, *, no_progress_count: int | None = None) -> bool:
"""Return whether another hidden continuation turn should run."""
if evaluation["satisfied"]:
return False
if evaluation["blocker"] not in CONTINUABLE_GOAL_BLOCKERS:
return False
if int(goal.get("continuation_count", 0)) >= int(goal.get("max_continuations", DEFAULT_MAX_GOAL_CONTINUATIONS)):
return False
current_no_progress = int(goal.get("no_progress_count", 0) if no_progress_count is None else no_progress_count)
max_no_progress = int(goal.get("max_no_progress_continuations", DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS))
return current_no_progress < max_no_progress
def latest_visible_assistant_signature(messages: list[Any]) -> str:
"""Return a stable signature of the latest visible assistant evidence.
The "no progress" breaker keys on what the agent actually produced — the
text of the most recent user-visible assistant message — not on the
evaluator's free-text ``reason``/``evidence_summary`` (which an LLM rewords
on every turn, so it almost never repeats byte-for-byte). When a
continuation adds no new visible assistant output, the signature is
unchanged and the breaker can recognise the stalled turn.
"""
for message in reversed(messages):
if not _is_visible_message(message) or _message_type(message) != "ai":
continue
text = message_to_text(message).strip()
if text:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
return ""
def compute_goal_progress_key(evaluation: GoalEvaluation, *, evidence_signature: str = "") -> str:
"""Return a stable key used to detect repeated non-progress evaluations.
Keyed on the typed ``blocker`` plus a signature of the visible assistant
evidence, so a stalled goal is detected even when the evaluator rewords its
free-text ``reason``/``evidence_summary``.
"""
return json.dumps(
{
"satisfied": evaluation["satisfied"],
"blocker": evaluation["blocker"],
"evidence_signature": evidence_signature,
},
ensure_ascii=False,
sort_keys=True,
)
def compute_no_progress_count(goal: GoalState, evaluation: GoalEvaluation, *, evidence_signature: str = "") -> int:
"""Increment repeated-progress count when visible evidence has not advanced."""
if evaluation["satisfied"]:
return 0
progress_key = compute_goal_progress_key(evaluation, evidence_signature=evidence_signature)
previous = goal.get("last_evaluation", {})
if isinstance(previous, dict) and previous.get("progress_key") == progress_key:
return int(goal.get("no_progress_count", 0)) + 1
return 0
def make_goal_continuation_message(goal: GoalState, evaluation: GoalEvaluation) -> HumanMessage:
"""Build the hidden user message that asks the agent to keep working."""
content = (
"<goal_continuation>\n"
f"Active goal: {goal['objective']}\n"
f"Evaluator result: not satisfied. Blocker: {evaluation['blocker']}. Reason: {evaluation['reason'] or 'No reason provided.'}\n"
f"Visible evidence: {evaluation.get('evidence_summary') or 'No evidence summary provided.'}\n"
"Continue working toward the active goal. Use the available tools and conversation context. "
"Do not ask the user to continue unless you are genuinely blocked.\n"
"</goal_continuation>"
)
return HumanMessage(
content=content,
additional_kwargs={
"hide_from_ui": True,
"deerflow_goal_continuation": True,
},
)
async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_name: str, *args: Any, **kwargs: Any) -> Any:
async_method = getattr(checkpointer, async_name, None)
if async_method is not None:
result = async_method(*args, **kwargs)
return await result if inspect.isawaitable(result) else result
sync_method = getattr(checkpointer, sync_name, None)
if sync_method is None:
raise AttributeError(f"Missing checkpointer method: {async_name}/{sync_name}")
# Offload the synchronous checkpointer call so its blocking IO never runs on
# the event loop (backend/AGENTS.md blocking-IO gate).
result = await asyncio.to_thread(sync_method, *args, **kwargs)
return await result if inspect.isawaitable(result) else result
def _next_channel_version(checkpointer: Any, current_version: Any) -> Any:
get_next_version = getattr(checkpointer, "get_next_version", None)
if callable(get_next_version):
return get_next_version(current_version, None)
if isinstance(current_version, int):
return current_version + 1
return 1
async def ensure_thread_checkpoint(checkpointer: Any, thread_id: str) -> None:
"""Create an empty root checkpoint for *thread_id* when none exists."""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", config)
if checkpoint_tuple is not None:
return
metadata = {
"step": -1,
"source": "input",
"writes": None,
"parents": {},
"created_at": now_iso(),
}
await _call_checkpointer_method(checkpointer, "aput", "put", config, empty_checkpoint(), metadata, {})
def _checkpoint_id_from_tuple(checkpoint_tuple: Any) -> str | None:
config = getattr(checkpoint_tuple, "config", {}) or {}
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
checkpoint_id = configurable.get("checkpoint_id") if isinstance(configurable, dict) else None
if isinstance(checkpoint_id, str):
return checkpoint_id
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
if isinstance(checkpoint, dict) and isinstance(checkpoint.get("id"), str):
return checkpoint["id"]
return None
async def read_thread_goal(checkpointer: Any, thread_id: str) -> GoalState | None:
"""Read the latest thread goal from checkpoint state."""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", config)
if checkpoint_tuple is None:
return None
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
raw_goal = channel_values.get("goal") if isinstance(channel_values, dict) else None
return copy.deepcopy(raw_goal) if isinstance(raw_goal, dict) else None
async def write_thread_goal(
checkpointer: Any,
thread_id: str,
goal: GoalState | None,
*,
as_node: str = "goal",
create_if_missing: bool = False,
expected_checkpoint_id: str | None = None,
) -> dict[str, Any]:
"""Write a new checkpoint with the thread goal set or cleared.
Returns the updated channel values.
"""
if create_if_missing:
await ensure_thread_checkpoint(checkpointer, thread_id)
read_config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
checkpoint_tuple = await _call_checkpointer_method(checkpointer, "aget_tuple", "get_tuple", read_config)
if checkpoint_tuple is None:
raise LookupError(f"Thread {thread_id} checkpoint not found")
if expected_checkpoint_id is not None and _checkpoint_id_from_tuple(checkpoint_tuple) != expected_checkpoint_id:
raise GoalWriteConflict(f"Thread {thread_id} goal checkpoint changed while preparing write")
checkpoint: dict[str, Any] = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
metadata: dict[str, Any] = dict(getattr(checkpoint_tuple, "metadata", {}) or {})
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}) or {})
if goal is None:
channel_values.pop("goal", None)
else:
channel_values["goal"] = copy.deepcopy(goal)
channel_versions = dict(checkpoint.get("channel_versions", {}) or {})
current_version = channel_versions.get("goal")
next_version = _next_channel_version(checkpointer, current_version)
channel_versions["goal"] = next_version
checkpoint["channel_values"] = channel_values
checkpoint["channel_versions"] = channel_versions
checkpoint["id"] = str(uuid6())
metadata["updated_at"] = now_iso()
metadata["source"] = "update"
metadata["step"] = metadata.get("step", 0) + 1
metadata["writes"] = {as_node: {"goal": goal}}
write_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
# Parent the new checkpoint to the one it was derived from.
# Without this the saver stores a parentless checkpoint, which
# severs Delta-channel replay ancestry (and truncates history
# walks in full mode too).
"checkpoint_id": _checkpoint_id_from_tuple(checkpoint_tuple),
}
}
await _call_checkpointer_method(checkpointer, "aput", "put", write_config, checkpoint, metadata, {"goal": next_version})
return channel_values
def attach_goal_evaluation(
goal: GoalState,
evaluation: GoalEvaluation,
*,
run_id: str,
continuation_count: int | None = None,
no_progress_count: int | None = None,
stand_down_reason: str | None = None,
evidence_signature: str = "",
) -> GoalState:
"""Return a goal copy with the latest evaluator result attached."""
next_goal = copy.deepcopy(goal)
if continuation_count is not None:
next_goal["continuation_count"] = continuation_count
if no_progress_count is not None:
next_goal["no_progress_count"] = no_progress_count
next_goal["updated_at"] = now_iso()
next_goal["last_evaluation"] = {
"satisfied": evaluation["satisfied"],
"blocker": evaluation["blocker"],
"reason": evaluation["reason"],
"evidence_summary": evaluation.get("evidence_summary", ""),
"run_id": run_id,
"evaluated_at": next_goal["updated_at"],
"progress_key": compute_goal_progress_key(evaluation, evidence_signature=evidence_signature),
}
if stand_down_reason:
next_goal["last_evaluation"]["stand_down_reason"] = stand_down_reason
return next_goal