DeepSeek Harness Session Log and Turn Lifecycle

Have you ever wondered: where exactly is the conversation history that the model sees stored?

dsh's answer is quite special:There is no dedicated place; rather, it is derived from a session log.。

In this section, we'll cover session logs and the turn lifecycle: how the log serves as the single source of truth, how turns and steps are divided, and the invariant that "what the model sees is what has been recorded."

In one sentence: a session is an append-only event log, model history is derived from the log; turns and steps are execution boundaries on the log.


Conversation log: the single source of truth

Sessionis a typedSessionEventthe resulting append-only log.

It is the single source of truth for the agent's complete interaction history; the LLM message history is derived from the log and never stored separately.

Replaying means re-deriving history from the same set of events.

Every event in the log has a monotonically increasingseq(seq = log.length) and epoch milliseconds oftime。

Events are based ontypeto form a true discriminated union, thereforeswitch (event.type)Can directly narrowevent.data, without type assertions.

Allevent.dataand must all be losslessly serializable to JSON,Session.appendwill enforce this at the source.

What is visible to the model is already recorded.Everything that reaches the model request must be reconstructable from the log, and a runtime invariant asserts this.

Therefore, adding a model-visible input requires adding a session event: extend SessionEventMap and render it from the log.

SDK users who need replayable transcript data should consumesession/eventEvent flow.


deriveMessages: projects model history from the log

Session.deriveMessages()Project the event log into what the model seesMessage[]。

It is cached: each surface node is projected once when it first appears, and rebuilt when the surface is rewritten.

It returns a frozen array of messages; modifying recorded history through projection is not expressible at the type level.

The projection rules are straightforward:

EventsProjected asDescription
user/messageone user messageCarries the exact content; the optional envelope serves only as log-display metadata.
assistant/messageone assistant messageContains provider, model, and optional replay state.
assistant/chunkskipBelongs to replay/UI data; the assembled message is authoritative.
tool/resultA user message with a tool-result blocktool results return to the model with the user role
turn/*、step/*skipstructural information, not projected as messages

One detail: content that is emptyassistant/messagewill also be skipped.

Steps truncated due to max-tokens with no content still record an assistant/message to preserve usage, provider, and model, but content-less assistant turns must not enter the provider transcript.


Three event domains

Choosing the right event domain is the first decision for most changes.

The official documentation divides events into three domains, each with its own purpose:

Event domainRepresentative eventFeaturesWhen to use
Session eventturn/start、step/start、user/message、assistant/*、tool/call、tool/resultappended to the log and broadcast, a persistent factA fact that must still exist after a reload
Agent eventagent/pre-step、agent/request、agent/status、agent/turn-stoppingCarries the live Agent, real-time control, and state.observe or intercept in-progress work
Capability eventtools/*、fs/*、llm/streamAttach policies to seams without import cycles.Hang policies and adapters on capability seams.

whereagent/pre-step、agent/request、llm/streamand threetools/*Events are a waterfall; listeners must callnext()only then can it be delegated further.

agent/turn-stoppingis a serial event, withoutnext()。


the definition of turns and steps

aStepIs one model request plus the tools it calls.

aTurnContains zero or more steps: it opens before claiming the first input and closes when no work is owed.

Note: a turn wraps one model-loop execution, not the entire session log.

The official sequence diagram depicts the complete flow as:turn/start → agent/pre-step → step/start → llm/stream → 工具 → step/end → turn/end。

轮次时序图

Inputs reach the driver through a single inbox.

Some messages wake the driver immediately; injected context stays in the inbox until another message wakes it.

agent/pre-stepDetermines what the model sees: listeners can rewrite claimed messages, or reject them outright.

When the first claim is rejected or overwritten to empty, a persistent turn without steps is still closed, so the log records this attempt.


Key events for turns and steps

Every boundary in the log has a corresponding event; below are the most commonly used ones.

EventsData carriedDescription
turn/start{ turn }Open a turn before the loop claims queued input or runs pre-steps
turn/end{ turn, reason }with TurnEndReason Close轮times(completed / aborted / blocked / error / max-tokens / interrupted)
step/start{ turn, step }Open a step within a turn
step/end{ turn, step }Close this step
user/messageUserMessageA tagged value shared by direct prompts, injected context, steering, and live inbox events.
assistant/chunk{ turn, step, chunk }Raw streaming chunks, token-level replay fidelity.
assistant/message{ turn, step, message, usage? }The assembled assistant message (used by derived history).
tool/call{ turn, step, callId, name, arguments }One tool call from a model request; arguments is the raw JSON string produced by the model.
tool/result{ turn, step, message, error?, meta? }the model-visible result of a completed tool call

A useful semantic:assistant/messageThe event records every successful provider call, including calls that return empty content or end with max-tokens.

Empty content does not enter the derived history, but the persisted event still preserves usage.

It passes throughsourceEventSeqsprecisely list the correspondingassistant/chunkevents, including explicit empty lists.


Hands-on example: parsing a JSONL session log

Below is a short session log (standard JSONL line packing layout) from a task to "fix a typo in the example repository."

Each line is a SessionEvent, containing type, seq, time, and data.

{"type":"turn/start","seq":0,"time":1755000000000,"data":{"turn":1}}
{"type":"step/start","seq":1,"time":1755000000010,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":2,"time":1755000000020,"data":{"role":"user","content":[{"type":"text","text":"Fix the typo in the example README."}]},"surfaceOp":"append","sourceEventSeqs":[0]}
{"type":"assistant/chunk","seq":3,"time":1755000000030,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","text":"I'll "}}}
{"type":"assistant/chunk","seq":4,"time":1755000000040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","name":"bash","arguments":"{\"command\":\"grep example README.md\"}"}}}
{"type":"assistant/message","seq":5,"time":1755000000050,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I'll search"},{"type":"tool_use","id":"call_1","name":"bash","input":{"command":"grep example README.md"}}]},"usage":{"inputTokens":12,"outputTokens":4}},"surfaceOp":"append","sourceEventSeqs":[3,4]}
{"type":"tool/call","seq":6,"time":1755000000060,"data":{"turn":1,"step":1,"callId":"call_1","name":"bash","arguments":"{\"command\":\"grep example README.md\"}"}}
{"type":"tool/result","seq":7,"time":1755000000070,"data":{"turn":1,"step":1,"message":{"role":"tool","toolName":"bash","content":"example","isError":false}},"surfaceOp":"append","sourceEventSeqs":[6]}
{"type":"step/end","seq":8,"time":1755000000080,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":9,"time":1755000000090,"data":{"turn":1,"reason":{"kind":"completed"}}}

Noteuser/message、assistant/message、tool/resultThree surface events carrysurfaceOpa marker, indicating how they are added to the derived surface.

turn/start、step/startBoundary events like these do not carry surfaceOp and are not projected as model messages.

Below, we replay this log in Python, rebuild the conversation, and mark the turn/step boundaries.

Example

# File path: examples/parse_session_log.py
# Parse a JSONL conversation log, reconstruct the model-visible dialogue, and mark turn/step boundaries.
# This is Session.deriveMessages() ofa简transformTeach学Model;truerealImplementationYesCachingofandreturn冻结message.
import json
import sys

def derive_messages(events):
    """Only project surface events, simulating the projection rules of deriveMessages.

user/message -> user message
assistant/message -> assistant message
tool/result -> user message carrying a tool-result block
turn/*, step/*, assistant/chunk, tool/call are not projected as messages
    """

    messages = []
    for ev in events:
        t = ev["type"]
        d = ev["data"]
        if t == "user/message":
            messages.append({"role": "user", "content": d["content"]})
        elif t == "assistant/message":
            messages.append({"role": "assistant", "content": d["message"]["content"]})
        elif t == "tool/result":
            messages.append({"role": "tool", "name": d["message"]["toolName"], "content": d["message"]["content"]})
    return messages

def main(path):
    with open(path, encoding="utf-8") as f:
        events = [json.loads(line) for line in f if line.strip()]

    # First pass: print execution boundaries to understand the nested relationship between turn and step.
    for ev in events:
        d = ev["data"]
        if ev["type"] == "turn/start":
            print(f"[turn/start] turn={d['turn']}")
        elif ev["type"] == "turn/end":
            print(f"[turn/end]   turn={d['turn']} reason={d['reason']}")
        elif ev["type"] == "step/start":
            print(f"  [step/start] turn={d['turn']} step={d['step']}")
        elif ev["type"] == "step/end":
            print(f"  [step/end]   turn={d['turn']} step={d['step']}")
        elif ev["type"] == "assistant/chunk":
            print(f"    chunk: {d['chunk']['type']}")
        elif ev["type"] == "tool/call":
            print(f"    tool/call: {d['name']} args={d['arguments']}")

    # Second pass: rebuild the derived history visible from the model.
    print("\nModel-visible derived messages:")
    for m in derive_messages(events):
        if m["role"] == "tool":
            print(f"  [tool] {m['name']}: {m['content']}")
        else:
            print(f"  [{m['role']}] {m['content']}")

if __name__ == "__main__":
    main(sys.argv[1])

Running it on this log, the output is roughly:

[turn/start] turn=1
  [step/start] turn=1 step=1
    chunk: text-delta
    chunk: tool-call-delta
    tool/call: bash args={"command":"grep example README.md"}
  [step/end]   turn=1 step=1
[turn/end]   turn=1 reason={'kind': 'completed'}

模型可见的派生消息:
  [user] [{'type': 'text', 'text': 'Fix the typo in the example README.'}]
  [assistant] [{'type': 'text', 'text': 'I'll search'}, {'type': 'tool_use', ...}]
  [tool] bash: example

originalassistant/chunkskipped during derivation, the assembledassistant/messageis the authority.

This is exactly "what the model sees is what has been recorded": every message the model sees can be rebuilt verbatim from this log.


Summary self-test

The session log is the sole source of the context the model sees; turns and steps are execution boundaries on the log; the three event domains help you choose the right extension point.

Self-test questions:

ProblemReference
Which event domain should you use to persist facts that must survive a reload?session events (persistent, appended to the log)
Where does the history seen by the model come from?Session.deriveMessages()Derived from the log, never stored separately.
How many steps can a turn contain?Zero or more; opens before claiming the first input, closes when no work is owed.
other extensions