Stage 06: The Case Is Still Waiting, but the Process Is Gone — Checkpoints, Memory, and HITL
Language: English | 简体中文
Stage 05 connected the Agent to Acme's external support service through MCP. It can read order, shipment, and invoice facts for ACME-1007, but one server capability was deliberately withheld from the model: create_support_case.
That restriction now becomes the starting point of this chapter. A read can return the wrong information, but creating a case leaves persistent business state behind. A refund, cancellation, or outbound message would have an even stronger side effect. So Lin chooses a simple rule: the model may propose the action, but a human must review it before execution.
The customer adds one more request:
“Please create a support case for ACME-1007 because I am requesting an original-payment refund on day 38. Also remember that I prefer concise Chinese replies.”
One sentence contains two very different kinds of information that must survive time. The pending business action has to survive until a reviewer returns. The answer preference should survive even after this thread is over. Both need storage, but they are not the same kind of storage.
We will follow that one request through the entire stage. First we pause durably for approval, deliberately let the Python process disappear, and resume from a second process. Only after that mechanism is clear do we introduce long-term memory and finally a live DeepSeek proposal step.
1. Why waiting at input() is not durable HITL
A toy approval demo can be only a few lines:
case = {"order_id": "ACME-1007", "reason": "day-38 refund review"}
answer = input("Approve? [yes/no] ")
if answer == "yes":
create_support_case(**case)
It demonstrates human review, but it silently depends on the original Python process staying alive. If the service is redeployed while the reviewer is away, the local variables and execution position disappear with that process.
A durable approval flow needs a different shape:
prepare action
↓
persist "waiting for approval"
↓
current process may disappear
↓
a new process restores the record
↓
human decision arrives
↓
continue execution
That is the plain-language idea behind durable Human-in-the-Loop. We will name the pieces only after the path itself is clear.
2. State describes the run; a checkpoint preserves that state
Stage 03 made State explicit. For the case action, the runtime needs at least:
@dataclass(frozen=True, slots=True)
class WorkflowState:
run_id: str
thread_id: str
owner_id: str
phase: str
action: str
arguments: dict[str, str]
result: dict[str, Any] | None = None
When phase="waiting_approval", the state says that no case has been created yet and execution is paused at a review boundary.
A checkpoint is simply a durable representation of that execution state:
State = what this run looks like now
Checkpoint = a durable copy of what this run looks like now
A checkpoint does not invent new business facts. It prevents existing execution facts from vanishing with process memory.
This is also why a chat transcript is not automatically a checkpoint. A conversation may mention an order, but it does not necessarily encode the current phase, the validated action arguments, or whether execution is waiting for a reviewer. Recovery should read explicit execution state rather than ask another model to infer progress from old prose.
3. owner_id, thread_id, and run_id answer different questions
For this example:
owner_id = user-7
thread_id = acme-refund-thread-001
run_id = acme-case-run-001
The scopes are different:
owner_id -> whose retained data is this?
thread_id -> which continuous task or conversation?
run_id -> which concrete execution attempt?
The checkpoint is located by run_id, but it still records the thread and owner. Long-term memory, in contrast, is scoped primarily by owner. A single generic ID would make those responsibilities much harder to distinguish once the application supports more than one user or task.
4. Persist waiting_approval outside the process
The chapter uses SQLite because Python already ships with sqlite3, not because SQLite is the only production choice. The checkpoint table is intentionally small:
CREATE TABLE IF NOT EXISTS checkpoints (
run_id TEXT PRIMARY KEY,
state_json TEXT NOT NULL
)
Saving the same run replaces the previous snapshot:
conn.execute(
"""
INSERT INTO checkpoints(run_id, state_json)
VALUES (?, ?)
ON CONFLICT(run_id) DO UPDATE SET state_json=excluded.state_json
""",
(state.run_id, payload),
)
The important change is architectural, not syntactic: the state now lives in a file that a later process can reopen.
When the workflow starts, it validates the action and persists the pause point:
state = WorkflowState(
run_id=run_id.strip(),
thread_id=thread_id.strip(),
owner_id=owner_id.strip(),
phase="waiting_approval",
action=CREATE_SUPPORT_CASE,
arguments=arguments,
)
self.store.save(state)
At this point, an approval request exists. A support case does not.
5. Prove recovery with separate Python processes
Start the run:
python stages/06-memory-persistence-hitl/code/demo.py --db stage06-demo.db start
That command exits. The original Python process is gone.
Now start a different process and inspect the same SQLite file:
python stages/06-memory-persistence-hitl/code/demo.py --db stage06-demo.db show
You should see ACME-1007, the pending action, its thread and owner, and the retained answer preference. The second process inherited no Python objects from the first one.
Finally, use a third process to apply a decision:
python stages/06-memory-persistence-hitl/code/demo.py \
--db stage06-demo.db resume --decision approve
The same run moves from waiting_approval to completed. With reject, it moves to rejected and no teaching side effect is recorded.
That simple experiment makes the durability boundary concrete:
old process memory gone
durable checkpoint still available
6. Human-in-the-Loop needs a concrete review object
The reviewer should know exactly what is waiting for approval. The workflow therefore produces a structured request:
@dataclass(frozen=True, slots=True)
class ApprovalRequest:
run_id: str
thread_id: str
owner_id: str
action: str
arguments: Mapping[str, Any]
reason: str
For this story, the important values are:
action = create_support_case
order_id = ACME-1007
reason = day-38 refund review
HITL is therefore not merely “let a human chat with the Agent.” It is a control boundary where execution pauses and a person reviews a specific proposed action.
7. An edited approval must be validated again
Reviewers often need more than yes/no. This example supports:
approve
edit
reject
If the reviewer edits the request, the edited arguments go through the same validation boundary:
edited = validate_case_arguments(decision.edited_arguments)
if edited["order_id"] != validated_original["order_id"]:
raise ValueError("an approval edit cannot switch to a different order")
return edited
An approval for ACME-1007 cannot be repurposed into an action on another order. Blank or excessively long reasons are rejected too.
Human review adds judgment. It does not disable validation.
8. Approval is not authorization
A person clicking “approve” is not proof that the person was allowed to approve this action. The workflow therefore receives a trusted reviewer context:
reviewer = ReviewerContext(
reviewer_id="reviewer-1",
allowed_actions=frozenset({CREATE_SUPPORT_CASE}),
)
Before execution:
def authorize_reviewer(request, reviewer):
if request.action not in reviewer.allowed_actions:
raise PermissionError(...)
This is intentionally a teaching-level allowlist, not a full identity system. In production, reviewer identity must come from authentication and the permissions should come from a real authorization policy.
Still, the distinction is now explicit:
Approval = what decision did the reviewer make?
Authorization = may this identity make that decision?
9. Durable recovery does not create exactly-once side effects by magic
Suppose a real remote create_support_case succeeds and the process crashes before the completed checkpoint is stored. A resumed process may call the remote service again.
Therefore:
durable resume != exactly-once side effect
The teaching implementation simulates case creation inside the same SQLite database. It derives a stable idempotency key:
idempotency_key = f"{waiting_state.run_id}:{waiting_state.action}"
and inserts the teaching effect under a unique key:
conn.execute(
"""
INSERT OR IGNORE INTO effects(idempotency_key, result_json)
VALUES (?, ?)
""",
(idempotency_key, encoded_result),
)
The effect record and completed checkpoint are then committed in the same local SQLite transaction. Replaying the same run therefore does not create a second teaching effect.
That guarantee ends at this database boundary. It does not automatically apply to a remote MCP server, payment API, or email provider. Real external effects need service-side idempotency keys, unique business constraints, status verification, or compensation strategies.
10. The user's “remember this” request is not a checkpoint
The same customer also said:
“Remember that I prefer concise Chinese replies.”
That preference is useful after this case is finished and even after a new thread begins. It is therefore a long-term memory concern, not run progress.
Keep the categories separate:
Checkpoint
how this run can continue
Thread / short-term history
what recently happened in this continuous task
Long-term Memory
retained user information that may matter across threads
RAG Knowledge
external evidence from documents
They may all be stored somewhere, but storage technology does not define their meaning.
11. A model can propose memory; it cannot grant itself permanent write access
A candidate memory is explicit application data:
@dataclass(frozen=True, slots=True)
class MemoryCandidate:
owner_id: str
key: str
value: dict[str, Any]
kind: MemoryKind
explicit_user_request: bool
source_thread_id: str
sensitive: bool = False
The candidate for this request can be:
MemoryCandidate(
owner_id="user-7",
key="answer-style",
value={"language": "Chinese", "style": "concise"},
kind="semantic",
explicit_user_request=True,
source_thread_id="acme-refund-thread-001",
)
Application policy still decides whether it is written:
decision = policy.evaluate(candidate)
if decision.store:
store.put(candidate)
The default teaching policy rejects sensitive data, incidental facts without an explicit memory request, and procedural self-rewrites. The same principle from the earlier stages still applies:
model proposal != application authority
12. Semantic, episodic, and procedural memory change different things
Semantic memory holds facts or preferences such as a language preference. Episodic memory represents past events such as what happened in a previous planning session. Procedural memory changes how work should be performed.
The last category carries a very different risk. Storing “prefer Chinese” incorrectly is inconvenient. Storing “skip refund approval from now on” as a behavioral rule changes system control.
This stage therefore rejects procedural self-rewrite by default. That does not mean procedural memory is impossible; it means it deserves stronger versioning, review, and deployment governance than an ordinary preference write.
13. Long-term memory needs an owner boundary from the beginning
The memory table includes owner_id in its primary key:
CREATE TABLE IF NOT EXISTS memories (
owner_id TEXT NOT NULL,
key TEXT NOT NULL,
kind TEXT NOT NULL,
source_thread_id TEXT NOT NULL,
value_json TEXT NOT NULL,
PRIMARY KEY (owner_id, key)
)
Reads also include the owner:
store.get("alice", "answer-style")
So Alice's answer-style record is not automatically returned for Bob.
A database column is not complete authorization. Production owner_id values must come from trusted identity, not from a model claim or untrusted request field. The important lesson here is to model ownership before the memory store becomes large and difficult to untangle.
14. Bring in DeepSeek only after the persistence rules are clear
The offline flow already demonstrates checkpointing, review, recovery, and memory policy. The live DeepSeek example has a deliberately narrower job: turn the user's message into a candidate reply, candidate action, and candidate memory.
deepseek_hitl.py uses the same Responses API style as the preceding stages:
response = self.client.responses.create(
model=self.model,
instructions=INSTRUCTIONS,
input=user_message,
max_output_tokens=4096,
)
The returned JSON still crosses application validation. Only create_support_case is accepted, the order ID must actually appear in the user's message, and the argument shape must match the allowed action:
if proposal.action["name"] != "create_support_case":
raise RuntimeError("the proposed action is not allowed in this stage")
if order_id.upper() not in mentioned:
raise RuntimeError("the model proposed an order ID absent from the user message")
The model never supplies the trusted owner_id or thread_id; those come from application context. After validation, the memory candidate goes through memory policy and the business action becomes a waiting_approval checkpoint. The model does not approve or execute the effect.
Configure an available DeepSeek model and run:
export DEEPSEEK_API_KEY="your-deepseek-api-key"
export DEEPSEEK_MODEL="your-available-model-id"
python stages/06-memory-persistence-hitl/code/deepseek_hitl.py --db stage06-live.db
PowerShell:
$env:DEEPSEEK_API_KEY="your-deepseek-api-key"
$env:DEEPSEEK_MODEL="your-available-model-id"
python stages/06-memory-persistence-hitl/code/deepseek_hitl.py --db stage06-live.db
The entry point prints live DeepSeek: API usage applies. Missing SDK or configuration fails explicitly rather than falling back to scripted output. The model run stops at the approval checkpoint; a later process can review it with demo.py resume using the same database and run ID.
15. Test the boundaries, not only the happy path
Run the offline checks:
python stages/06-memory-persistence-hitl/code/checks.py
The checks cover owner isolation, memory write policy, sensitive and procedural rejection, unauthorized reviewers, edited-argument validation, reject-without-effect behavior, repeated resume, strict model proposal parsing, order-ID grounding, and a true cross-process start → show → resume recovery sequence.
Two limits remain explicit. The reviewer allowlist is not a production identity system, and SQLite-local idempotency does not prove a remote MCP service has exactly-once semantics.
16. Closing the stage: we can retain information; next we must select it
The complete story now looks like this:
user requests an ACME-1007 case + asks to remember a preference
↓
model/application produce candidate action and candidate memory
↓
memory policy decides whether the preference may persist
↓
business action becomes a waiting_approval checkpoint
↓
original process may exit
↓
new process restores the run
↓
authorized reviewer approves / edits / rejects
↓
edited data is validated again
↓
controlled teaching effect + final checkpoint
The system now retains several different kinds of information: execution state, checkpoints, thread history, long-term memory, RAG evidence, tool observations, and MCP resources.
That creates the next problem immediately:
Just because we stored all of this, should every model call receive all of it?
No. A database is a warehouse; a model context window is a limited desk. Stage 07 is about choosing what belongs on that desk for the current decision.