Skip to content

Latest commit

 

History

History
136 lines (102 loc) · 5.88 KB

File metadata and controls

136 lines (102 loc) · 5.88 KB

SDK Session Management

Multi-backend note (2026-07): everything below applies to the Claude backend specifically; the same concepts (native session id in sessions.sdk_session_id, resume, fork) are implemented per backend behind nerve/agent/backends/. The sessions.backend column records which runtime owns a session — resolution is sticky (stored backend beats config) and cross-backend resume is refused (the native id is cleared and the conversation restarts). Codex threads resume via thread/resume and fork via thread/fork; a stale codex thread id is recovered by falling back to a fresh thread (resume_dropped).

Core Principle

The Claude Agent SDK manages conversation context internally. Never load messages into prompts manually. Use resume and fork_session to give the agent context.

Permissions & Interactive Tools

Nerve uses a can_use_tool callback instead of bypassPermissions. This enables mid-turn pausing for interactive tools while auto-approving everything else:

options = ClaudeAgentOptions(
    can_use_tool=handler.can_use_tool,  # replaces permission_mode
)

The SDK sets --permission-prompt-tool stdio automatically, routing all tool permission requests through the control protocol. The InteractiveToolHandler (in nerve/agent/interactive.py) handles each request:

  • Non-interactive tools (Read, Bash, MCP, etc.) → PermissionResultAllow() immediately
  • AskUserQuestion → Broadcasts question to UI via WebSocket interaction event, awaits user answer, returns PermissionResultAllow(updated_input={...answers}) — the SDK injects answers into the tool's answers field
  • ExitPlanMode / EnterPlanMode → Broadcasts approval request, awaits user decision, returns allow or deny

Handlers are registered per-session in a global registry (interactive._handlers). The WebSocket server routes answer_interaction messages to the correct handler by session ID.

How It Works

Each conversation session gets an sdk_session_id — a unique identifier the SDK uses to store and restore full conversation state (messages, tool calls, thinking blocks, everything).

Resume (Continue a Session)

When you want to continue an existing conversation:

options = ClaudeAgentOptions(
    resume=session.sdk_session_id,  # SDK restores full context
    fork_session=False,
)

The SDK restores the entire conversation history. No manual message loading needed.

Nerve's implementation: engine._get_or_create_client() checks the session's sdk_session_id column and passes it as resume to the SDK options. See engine.py:330-403.

Fork (Branch a Conversation)

When you want a new session that starts with the parent's full context:

options = ClaudeAgentOptions(
    resume=parent.sdk_session_id,   # Branch FROM this point
    fork_session=True,              # Create independent branch
    resume_session_at=turn_uuid,    # Optional: truncate at a mid-point turn
)

The fork inherits all parent context but diverges from that point. New messages don't affect the parent.

Mid-point forks. Both backends can branch at an earlier turn, not just the end: Claude via resume_session_at (a transcript-entry UUID), Codex via thread/fork + lastTurnId. To make that possible, each backend records a per-turn anchor as messages.native_turn_id on the completed assistant row — Codex reports the turn id natively; the Claude client tracks the last main-chain transcript-entry uuid streamed during the turn (ClaudeClient._translate_and_capture). db.get_native_turn_at_message() maps a UI message id to that anchor. Message forks on rows with no anchor (history predating this recording) are refused rather than silently forking the full context.

Display parity. SessionManager.fork_session() copies the source's message rows up to the fork boundary into the fork (db.copy_messages_to_session), so the forked chat shows the history the agent actually remembers. Copies keep native_turn_id (forks are re-forkable) and get external_id = "forkcopy:<source row id>" for provenance.

Nerve's implementation: engine.run() detects parent_session_id on first message (status=CREATED) and sets fork_from (+ fork_last_turn_id for message-anchored forks). See _get_or_create_client / _run_inner in engine.py.

Session ID Lifecycle

New session → first message → ResultMessage.session_id → stored in DB
     ↓
Subsequent messages → resume=stored_id → SDK continues
     ↓
Stop/Idle → sdk_session_id preserved → can resume later
     ↓
Error → sdk_session_id cleared → must start fresh
     ↓
Fork → parent's sdk_session_id used once → fork gets own new ID

When to Use What

Scenario Approach
Continue user conversation resume (automatic in _get_or_create_client)
Cron job (no context needed) New isolated session
Webhook handler New isolated session

Anti-Patterns

NEVER do this:

# BAD: loading messages manually into the prompt
messages = await db.get_messages(session_id, limit=20)
prompt = "Recent context:\n" + "\n".join(msg["content"] for msg in messages)

This loses tool call history, thinking blocks, system prompt context, and the SDK's internal state management.

Do this instead:

# GOOD: fork from the session with context
fork = await engine.fork_session(source_session_id, source="cron")
await engine.run(session_id=fork["id"], user_message=prompt)
# SDK handles all context through the fork

Database Columns

The sessions table has these SDK-related columns:

  • sdk_session_id — The SDK's internal session identifier (set after first message)
  • parent_session_id — For forks, points to the source session
  • forked_from_message — Optional: specific message to branch from
  • connected_at — When the SDK client connected (used for memorization watermark)