Skip to content

Latest commit

 

History

History
252 lines (178 loc) · 15.4 KB

File metadata and controls

252 lines (178 loc) · 15.4 KB

Multi-Turn Sessions

odek supports multi-turn conversations — save a session, continue it later, and maintain full conversation history across multiple agent runs.

Session basics

Each session is stored as a JSON file in ~/.odek/sessions/<id>.json with the full conversation transcript including system message, user turns, assistant responses, tool calls, and tool results.

Creating a session

# Save the conversation as a session
odek run --session "Refactor the auth module to use JWT"

Continuing a session

# Continue the most recent session
odek continue "Now add refresh token support"

# Continue a specific session by ID
odek continue --id 20260518-abc123 "Add unit tests"

Session listing

# List recent sessions (max 20)
odek session list

# Example output:
# ID                     Turns Model                          Task
# ────────────────────────────────────────────────────────────────
# 20260518-abc123        3    deepseek-v4-flash               Refactor the auth module...
# 20260517-def456        1    gpt-4o                          Fix the OOM bug in defau...

Viewing a session

# Show the most recent session transcript
odek session show

# Show a specific session
odek session show 20260518-abc123

Deleting a session

# Delete a single session
odek session delete 20260518-abc123

The unread-script gate keeps per-session read receipts in process memory (a script the agent read this session may run without the extra prompt; an unread one prompts). They are never written to the session file. The Web UI drops a session's receipts when the session is deleted, /new in Telegram drops the chat's, and a scheduled run drops its own when it ends; a session resumed later starts with an empty ledger, so a script read in an earlier turn of a previous process gates again until it is re-read.

Trimming a session

Keeps only the n most recent messages, always preserving the system prompt:

# Keep last 10 messages (system + 9)
odek session trim 20260518-abc123 10
# → Trimmed session 20260518-abc123: 50 → 10 messages (40 dropped)

Size cap and the original task

Session files are capped at MaxSessionFileBytes (32 MiB). When a save would exceed the cap, the oldest message groups are dropped first, but the protected head is kept: the system prompt and the first user message (the original task). A marker system message follows that head to record how many groups were removed. Only when the head alone exceeds the cap is the task dropped, so the file always stays loadable.

Saving also redacts secrets from assistant tool-call arguments (structurally: every string value and key of the JSON is redacted and re-marshalled so the arguments stay valid JSON; non-JSON arguments are redacted as text, falling back to {"redacted":true}) and external-ref URIs (the per-session audit log redacts its user messages, ingest sources and resource lists the same way), and deleting a session (or sweeping it with cleanup) removes its audit log as well.

Cleaning up old sessions

Deletes all sessions whose UpdatedAt timestamp is older than N days:

# Remove sessions untouched in 30+ days
odek session cleanup 30
# → Cleaned up 3 session(s) older than 30 days.

# Wipe every session
odek session cleanup 0
# → Cleaned up 12 session(s) older than 0 days.

Vector index maintenance: odek session cleanup also removes stale entries from the vector index — orphaned vectors no longer accumulate in vectors.gob. This was a bug fix in v0.58.2: the primary cleanup path previously bypassed Vec.Remove(), leaving orphaned vectors that silently accumulated.

Listing index recovery: listings, odek continue and cleanup read the session metadata index (index.json). If that file cannot be parsed, odek rebuilds the index from the session files instead of treating it as empty, so the next save rewrites a complete index rather than one that lists only the session being saved.

Session Search Tool

The session_search tool (available inside the agent loop, not as a CLI command) lets the agent browse, search, and recall past sessions by semantic content. This is the primary mechanism for revisiting historical conversations.

Action Description
list Recent sessions (metadata only: ID, task, turns, timestamps, model)
search Semantic keyword search through full message content using vector similarity
get Full session by ID — returns ALL messages including tool calls and results
find Find sessions by task/title

How search works — two-tier pipeline:

  1. Vector index (fast path) — sessions are embedded into a persisted vector store and ranked by cosine similarity in ~1ms with zero LLM calls. The embedder is the shared backend (internal/embedding): by default local go-vector RandomProjections (256-dim, lexical), or — when an embedding block is configured (the shared top-level default, or a sessions.embedding override) — any OpenAI-compatible HTTP embeddings API, giving semantic matches that the lexical default can't (a query and a session that share no words can still match by meaning). See CONFIG.md → Shared embedding backend.
  2. DeepSearch (fallback) — when vector results are insufficient (<2 distinct token matches), or while the embedding backend is unavailable, falls back to exhaustive text search through session files on disk. Requires 2+ distinct token matches to qualify.

Resilience & backend switching: the index records which embedding space it was built in (vectors_meta.json). Changing provider/model/dims forces a one-time rebuild from the session files; a down HTTP backend degrades search to the DeepSearch tier and backs off for 30s — it never fails a session save or blocks the loop.

IMPORTANT: After search returns matching session IDs, use get (not search) to read the actual conversation content. get returns the full session_messages array with every user and assistant message.

From inside the Telegram bot, session recall works in the same turn: the current user message is persisted to the session store before the agent loop runs, so session_search can find the current conversation's data during the same turn.

Programmatic API

In-module only. The snippet below imports internal/session, which Go only allows inside this module. External programs should use agent.Run and the other public-type methods; RunWithMessages and the session store are in-module APIs. For external session handling, use odek run / odek continue or the REST API (API.md).

import "github.com/BackendStack21/odek/internal/session" // in-module only

agent, err := odek.New(odek.Config{...})

// Multi-turn with explicit message history
messages := []session.Message{
    {Role: "system", Content: systemPrompt},
    {Role: "user", Content: task},
}
result, allMessages, err := agent.RunWithMessages(ctx, messages)

// Save to store for later continuation
store, _ := session.NewStore()
sess, _ := store.Create(allMessages, model, task)
fmt.Printf("Session %s saved\n", sess.ID)

Storage format

Sessions are stored as compact JSON at ~/.odek/sessions/<id>.json (no indentation — ~5% smaller on disk, faster serialization):

{
  "id": "20260518-abc123",
  "created_at": "2026-05-18T07:17:00Z",
  "updated_at": "2026-05-18T07:22:00Z",
  "model": "deepseek-v4-flash",
  "turns": 3,
  "task": "Refactor the auth module to use JWT",
  "messages": [
    {"role": "system", "content": "..."},
    {"role": "user", "content": "Refactor the auth module..."}
  ],
  "external_refs": [
    {"kind": "ci-run", "uri": "https://ci.example.test/runs/4821", "created_by": "ci-orchestrator", "read_only": true, "created_at": "2026-05-18T07:17:00Z"}
  ]
}

A session whose transcript has ever carried external untrusted content (an <untrusted_content_…> wrapper whose source is not one of the engine's own context labels — compaction, plan, plan_remaining, memory, persisted_system, progress_summary, completed_effects, skill, extended_memory, return_after_break; see session.EngineDerivedSource) is saved with "untrusted_ingested": true. A plan, digest or memory block alone never flags a session; any tool result, @-ref, attachment, MCP output, session_search result, sub-agent result, background notice or project AGENTS.md does. The flag is sticky — a save never clears it once it is on disk, and files written before it existed derive it from their history on load — so trimming or compacting the content away does not make a resumed run look clean; resumed runs start tainted and delegate_tasks clamps their children to untrusted (see SECURITY.md). Before each save the CLI surfaces also set the flag when the run itself was tainted (agent.UntrustedIngested(), e.g. an MCP tool catalogue that never reaches the history).

A second sticky flag, "episode_untrusted": true, records the memory gate's narrower taint: a tool call that crossed the episode trust boundary (session.ToolCallTaintsEpisode — a read outside the workspace, an MCP, network or sub-agent tool, a shell command with a network or unknown effect) or wrapped external content in a non-tool message (session.EpisodeTaintSources). Workspace reads and local commands do not set it, so a session can carry untrusted_ingested and still produce a recallable episode. Each save ORs in the flag already on disk (including on the cached-revision fast path) and the taint of the messages it writes, before write-time size trimming drops the oldest groups. Both flags are scanned independently of the redaction boundary: a save skips only a message prefix that this process's last committed save of the same revision already scanned, verified by a digest over every message in that prefix, so a snapshot that rewrites any earlier message is scanned whole. A save over a symlinked session entry never reads the previous revision, so it sets both flags; an alias-backed session is therefore permanently untrusted for delegation and for episodes. Whether a read is outside the workspace is judged against the saving process's working directory (plus the sandbox /workspace mount and ~/.odek), so run odek from the workspace root. Telegram /resume carries an archive's flags into the chat's rebuilt session, and archiving keeps a chat's in-run taint. In-loop context trimming and compaction only shorten the model's view; the persisted transcript keeps every completed tool call until a save has judged it. "episode_taint_tracked": true marks a file whose flag covers its whole history; files written before these fields existed derive the flag from their history once on load. Episode writers read it through memory.DeriveSessionProvenance(sess), so an episode written from a trimmed history is still untrusted.

Embedders resuming a stored session with RunWithMessages should do the same:

ctx := context.Background()
if sess.UntrustedIngested {
    ctx = odek.WithUntrustedIngest(ctx)
}
_, history, err := agent.RunWithMessages(ctx, append(sess.Messages, userMsg))
sess.Messages = history
if agent.UntrustedIngested() {
    sess.UntrustedIngested = true
}
_ = store.Save(sess)

The Session struct has all public fields, enabling direct manipulation. This makes advanced operations (editing, truncating, merging) trivial — load, mutate, save.

External state references

Sessions may carry operator-supplied pointers to state that lives outside odek — a CI run, a dashboard, an object-store key (schema odek-extension/v1, see EXTENSIONS.md). The opacity guarantee: odek stores and transports these refs verbatim but never resolves or dereferences their URIs — there is no fetch/resolve code path, so a ref can never become an exfiltration or SSRF vector.

# Attach at session creation (repeatable)
odek run --session \
  --external-ref kind=ci-run,uri=https://ci.example.test/runs/4821,created_by=ci-orchestrator,read_only=true \
  "Summarize the failing tests"

# Shorthand kind=uri (created_by defaults to "cli")
odek run --session --external-ref ci-run=https://ci.example.test/runs/4821 "…"

# Attach to an existing session on continue (adds, never removes)
odek continue --external-ref dashboard=https://grafana.example.test/d/abc "…"

Rules:

  • kind: 1–64 chars, [a-z0-9_-]; uri: 1–2048 chars, no control characters; created_by: 1–128 chars
  • Refs are deduplicated on (kind, uri, created_by); created_at is stamped when absent
  • Malformed --external-ref values are fatal with a message naming the flag — the operator explicitly asked for the ref, so silently dropping it would violate least surprise
  • On odek run, refs given without --session print a warning and are not persisted
  • Refs survive Save/Append, the write-path size-cap trim, the redaction boundary, and odek continue; session files written before the field existed load unchanged

Programmatically: Config.ExternalRefs []session.ExternalRef (validated by odek.New) or sess.AddExternalRefs(...) on a loaded session. Tests: internal/session/external_ref_test.go, cmd/odek/external_ref_test.go.

Sandbox persistence

When a session is created with --sandbox, the sandbox flag is stored in the session file. On resume (odek continue or odek repl --id <id>), the sandbox is automatically re-enabled even if the current config has it disabled:

odek run --session --sandbox "Install deps and build"
# → session saved with sandbox=true

# Later, in a different terminal without sandbox config:
odek continue "Run the test suite"
# → odek: session was sandboxed — enabling sandbox for this continuation

This prevents accidentally escaping the sandbox on resume. The sandbox image/network/memory still come from the current config — only the toggle bit is persisted. To force-disable sandbox on resume, set "sandbox": false in trusted config (~/.odek/config.json) or ODEK_SANDBOX=false — those mark the policy explicit before the pin. ODEK_NO_SANDBOX=1 does not override a sandboxed session. odek continue does not accept --no-sandbox, and "sandbox": false in ./odek.json is ignored. A sandboxed resume hard-fails if Docker is unavailable (the pin is an explicit want).

Provider persistence

New sessions also store provider (the go-llm-sdk id used for the run). odek continue restores provider + model so a --provider anthropic session does not resume against the operator's current default provider.

Pre-v2 session files have an empty provider. Resume then uses the current default provider with the stored model id — rewrite those sessions or pass --provider on a new run if the pair would mismatch. REPL and Web UI stamp provider on newly created sessions the same way.

REPL sandbox flags

odek repl accepts the same sandbox CLI flags as odek run. You can start a sandboxed REPL session directly from the command line:

# Start a sandboxed REPL session
odek repl --sandbox

# With custom image and network isolation
odek repl --sandbox --sandbox-image node:20-alpine --sandbox-network none

# Resume a sandboxed session (sandbox auto-enabled)
odek repl --id 20260518-abc123

Sandbox state is saved with the session — resuming via --id auto-enables the sandbox container on startup.