From 2a97e1aaf339e40ad8b2695d9998f07b77a9da92 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Fri, 10 Jul 2026 18:54:53 -0700 Subject: [PATCH 1/2] Add Deep Agents Code envelope adapter --- PARITY.md | 18 ++ README.md | 68 +++++ .../deepagents-code/cleanup/expected.json | 62 ++++ fixtures/deepagents-code/cleanup/input.json | 40 +++ .../deepagents-code/tool-calls/expected.json | 73 +++++ .../deepagents-code/tool-calls/input.json | 91 ++++++ python/src/trajectory/__init__.py | 6 + python/src/trajectory/_types.py | 35 ++- .../src/trajectory/_vendor/trajectory-cli.mjs | 219 +++++++++++++- python/tests/test_wrapper.py | 2 + src/adapters/deepagents-code.ts | 279 ++++++++++++++++++ src/index.ts | 7 + src/types.ts | 2 + test/normalize.test.ts | 66 +++-- 14 files changed, 940 insertions(+), 28 deletions(-) create mode 100644 fixtures/deepagents-code/cleanup/expected.json create mode 100644 fixtures/deepagents-code/cleanup/input.json create mode 100644 fixtures/deepagents-code/tool-calls/expected.json create mode 100644 fixtures/deepagents-code/tool-calls/input.json create mode 100644 src/adapters/deepagents-code.ts diff --git a/PARITY.md b/PARITY.md index ebfc11b..d91d5a1 100644 --- a/PARITY.md +++ b/PARITY.md @@ -67,3 +67,21 @@ A Python-generated fixture was also opened with the official JavaScript JavaScript saver rejected Python's `msgpack` serializer type. The production adapter therefore delegates to the official Python saver and message reducer instead of decoding SQLite blobs or assuming cross-language wire compatibility. + +## Deep Agents Code boundary + +Deep Agents Code `0.1.36` was inspected against its official persistence code +and pinned `langgraph-checkpoint-sqlite` `3.1.0` / `langgraph-checkpoint` +`4.1.1` dependencies. Root and subagent conversations share a thread ID and +are separated by `checkpoint_ns`; current message state may require replaying +the parent chain and `messages` writes through the Deep Agents reducer rather +than reading only the latest checkpoint blob. + +An interoperability probe confirmed that a database written by the official +Python `SqliteSaver` `3.1.0` is not readable by the official JavaScript +`SqliteSaver` `1.0.3`: the JavaScript implementation rejects Python's +`msgpack` serialization tag. Consequently, this package does not decode the +SQLite store or its blobs. The `deepagents-code` adapter normalizes a versioned, +plain-JSON envelope after the shared official-Python helper performs thread +selection, namespace-aware reconstruction, and reducer replay. Deterministic +fixtures cover the envelope boundary. diff --git a/README.md b/README.md index 1f3a53b..b3950c7 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,78 @@ and is empty when the transcript required no recoverable cleanup. | --- | --- | --- | | `claude-code` | Native Claude Code JSONL | `claude-code` | | `codex` | Native Codex rollout JSONL | `codex` | +| `deepagents-code` | Version 1 safe JSON envelope of a reconstructed Deep Agents Code thread | `deepagents-code` | | `letta` | Cloud/API message array or local conversation JSONL (legacy and v3) | `letta` | | `openhands` | JSON event array or an events-API `{ "items": [...] }` envelope | `openhands` | | `deepagents` | User-supplied Python LangGraph `SqliteSaver` database plus `threadId` | `deepagents` | +### Deep Agents Code envelopes + +`deepagents-code` accepts a JSON string containing a safe, already-decoded +thread envelope: + +```json +{ + "type": "deepagents-code-thread", + "version": 1, + "thread_id": "019b0000-0000-7000-8000-000000000001", + "checkpoint_ns": "", + "metadata": { + "cwd": "/workspace/project", + "git_branch": "feature/example", + "created_at": "2026-02-01T12:00:00Z", + "updated_at": "2026-02-01T12:00:04Z" + }, + "messages": [ + { + "message": { + "type": "human", + "content": "Inspect the project.", + "id": "human-1" + }, + "timestamp": "2026-02-01T12:00:00Z" + }, + { + "message": { + "type": "ai", + "content": "I will inspect it.", + "id": "ai-1" + }, + "timestamp": "2026-02-01T12:00:01Z" + } + ] +} +``` + +Pass the serialized envelope through the regular API: + +```ts +const result = normalizeTranscript({ + source: "deepagents-code", + transcript: JSON.stringify(envelope), +}); +``` + +`checkpoint_ns` identifies the selected root (`""`) or subagent namespace. +Each `message` is an ordinary JSON dictionary in LangChain message shape; +`human`, `ai`, `system`, `tool`, `function`, and `remove` types are recognized. +Decoded class names may also be supplied as `__langgraph_class` (for example, +`"AIMessage"`). AI reasoning in `additional_kwargs.reasoning_content`, prose, +tool calls, linked tool results, model metadata, and optional per-message +timestamps are preserved. System messages are omitted with a diagnostic because +trajectory-v1 has no system role. Removal records are expected to have already +been applied while reconstructing checkpoint state and are ignored defensively. + +This package does **not** read `~/.deepagents/.state/sessions.db` or deserialize +LangGraph blobs. Deep Agents Code uses the Python LangGraph SQLite checkpointer +and MessagePack serializer; the official JavaScript SQLite checkpointer cannot +read that representation. Thread discovery, selection, checkpoint-namespace +handling, and reducer replay will therefore delegate to the upcoming shared +official-Python helper, which will emit this envelope. Until that helper is +available, callers must supply an envelope from a trusted exporter. Do not use +ad hoc object construction, pickle, or executable deserialization on checkpoint +blobs. + Letta messages use native `message_type` values such as `user_message`, `reasoning_message`, `assistant_message`, `tool_call_message`, `approval_request_message`, and `tool_return_message`. The adapter orders a diff --git a/fixtures/deepagents-code/cleanup/expected.json b/fixtures/deepagents-code/cleanup/expected.json new file mode 100644 index 0000000..c0148e0 --- /dev/null +++ b/fixtures/deepagents-code/cleanup/expected.json @@ -0,0 +1,62 @@ +{ + "records": [ + { "role": "meta", "source": "deepagents-code" }, + { + "role": "user", + "content": "Continue safely.", + "timestamp": "2026-02-02T08:00:00.000Z" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_2", + "name": "unknown_tool", + "args": "{\"_raw\":\"[\\\"unexpected\\\",\\\"shape\\\"]\"}" + } + ], + "timestamp": "2026-02-02T08:00:15.000Z" + }, + { + "role": "assistant", + "content": "Done.", + "timestamp": "2026-02-02T08:00:30.000Z" + } + ], + "diagnostics": [ + { + "code": "system_message_dropped", + "message": "Dropped a Deep Agents Code system message because trajectory-v1 has no system role." + }, + { + "code": "system_message_dropped", + "message": "Dropped a synthetic Deep Agents Code system notification." + }, + { + "code": "tool_call_id_synthesized", + "message": "Synthesized tool-call ID \"call_2\".", + "recordIndex": 2 + }, + { + "code": "unknown_tool_name", + "message": "Substituted \"unknown_tool\" for a missing tool name.", + "recordIndex": 2 + }, + { + "code": "tool_arguments_reshaped", + "message": "Reshaped arguments for tool call \"call_2\" into a JSON object.", + "recordIndex": 2 + }, + { + "code": "orphan_tool_result", + "message": "Dropped a tool result without a preceding call for \"\".", + "recordIndex": 3 + }, + { + "code": "timestamps_synthesized", + "message": "Synthesized timestamps for 3 normalized records.", + "count": 3 + } + ] +} diff --git a/fixtures/deepagents-code/cleanup/input.json b/fixtures/deepagents-code/cleanup/input.json new file mode 100644 index 0000000..c7c09cf --- /dev/null +++ b/fixtures/deepagents-code/cleanup/input.json @@ -0,0 +1,40 @@ +{ + "type": "deepagents-code-thread", + "version": 1, + "thread_id": "cleanup-thread", + "checkpoint_ns": "task:subagent-1", + "metadata": { "created_at": "2026-02-02T08:00:00Z" }, + "messages": [ + { + "message": { + "__langgraph_class": "SystemMessage", + "content": "Subagent system prompt" + } + }, + { + "message": { + "type": "human", + "content": "[SYSTEM] Tool execution was cancelled." + } + }, + { + "message": { "type": "human", "content": "Continue safely." } + }, + { + "message": { + "type": "ai", + "content": "", + "tool_calls": [{ "args": ["unexpected", "shape"] }] + } + }, + { + "message": { "type": "tool", "content": "unlinked output" } + }, + { + "message": { "type": "remove", "id": "old-message" } + }, + { + "message": { "type": "ai", "content": "Done." } + } + ] +} diff --git a/fixtures/deepagents-code/tool-calls/expected.json b/fixtures/deepagents-code/tool-calls/expected.json new file mode 100644 index 0000000..99407a0 --- /dev/null +++ b/fixtures/deepagents-code/tool-calls/expected.json @@ -0,0 +1,73 @@ +{ + "records": [ + { + "role": "meta", + "source": "deepagents-code", + "cwd": "/workspace/deep-agent", + "git_branch": "feature/checkpoints", + "model": "example-model" + }, + { + "role": "user", + "content": "Inspect the project and the screenshot.\n[image]", + "timestamp": "2026-02-01T12:00:00.000Z" + }, + { + "role": "reasoning", + "content": "First inspect the manifest and source.", + "timestamp": "2026-02-01T12:00:01.000Z" + }, + { + "role": "assistant", + "content": "I will inspect both files.", + "timestamp": "2026-02-01T12:00:01.000Z" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call-read", + "name": "read_file", + "args": "{\"file_path\":\"package.json\"}" + } + ], + "timestamp": "2026-02-01T12:00:01.000Z" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call-search", + "name": "search_files", + "args": "{\"query\":\"checkpoint\",\"path\":\"src\"}" + } + ], + "timestamp": "2026-02-01T12:00:01.000Z" + }, + { + "role": "tool", + "tool_call_id": "call-read", + "content": "{\"name\":\"trajectory\"}", + "timestamp": "2026-02-01T12:00:02.000Z" + }, + { + "role": "tool", + "tool_call_id": "call-search", + "content": "src/core.ts", + "timestamp": "2026-02-01T12:00:03.000Z" + }, + { + "role": "assistant", + "content": "The project contains the checkpoint integration point.", + "timestamp": "2026-02-01T12:00:04.000Z" + } + ], + "diagnostics": [ + { + "code": "system_message_dropped", + "message": "Dropped a Deep Agents Code system message because trajectory-v1 has no system role." + } + ] +} diff --git a/fixtures/deepagents-code/tool-calls/input.json b/fixtures/deepagents-code/tool-calls/input.json new file mode 100644 index 0000000..9422c69 --- /dev/null +++ b/fixtures/deepagents-code/tool-calls/input.json @@ -0,0 +1,91 @@ +{ + "type": "deepagents-code-thread", + "version": 1, + "thread_id": "019b0000-0000-7000-8000-000000000001", + "checkpoint_ns": "", + "metadata": { + "cwd": "/workspace/deep-agent", + "git_branch": "feature/checkpoints", + "agent_name": "coding-agent", + "created_at": "2026-02-01T12:00:00Z", + "updated_at": "2026-02-01T12:00:04Z" + }, + "messages": [ + { + "message": { + "type": "system", + "content": "Internal harness instructions", + "id": "system-1" + }, + "timestamp": "2026-02-01T12:00:00Z" + }, + { + "message": { + "role": "user", + "content": [ + { "type": "text", "text": "Inspect the project and the screenshot." }, + { "type": "image" } + ], + "id": "human-1" + }, + "timestamp": "2026-02-01T12:00:00Z" + }, + { + "message": { + "__langgraph_module": "langchain_core.messages.ai", + "__langgraph_class": "AIMessage", + "content": [ + { "type": "output_text", "text": "I will inspect both files." }, + { "type": "reasoning", "reasoning": "First inspect the manifest and source." } + ], + "additional_kwargs": { + "reasoning_content": "First inspect the manifest and source." + }, + "response_metadata": { "model_name": "example-model" }, + "id": "ai-1", + "tool_calls": [ + { + "id": "call-read", + "name": "read_file", + "args": { "file_path": "package.json" }, + "type": "tool_call" + }, + { + "id": "call-search", + "name": "search_files", + "args": { "query": "checkpoint", "path": "src" }, + "type": "tool_call" + } + ] + }, + "timestamp": "2026-02-01T12:00:01Z" + }, + { + "message": { + "type": "tool", + "content": "{\"name\":\"trajectory\"}", + "tool_call_id": "call-read", + "id": "tool-1" + }, + "timestamp": "2026-02-01T12:00:02Z" + }, + { + "message": { + "__langgraph_class": "ToolMessage", + "content": [{ "type": "text", "text": "src/core.ts" }], + "tool_call_id": "call-search", + "id": "tool-2" + }, + "timestamp": "2026-02-01T12:00:03Z" + }, + { + "message": { + "type": "ai", + "content": "The project contains the checkpoint integration point.", + "response_metadata": { "model": "example-model" }, + "id": "ai-2" + }, + "timestamp": "2026-02-01T12:00:04Z" + } + ] +} diff --git a/python/src/trajectory/__init__.py b/python/src/trajectory/__init__.py index f2e59a0..66912d9 100644 --- a/python/src/trajectory/__init__.py +++ b/python/src/trajectory/__init__.py @@ -11,6 +11,9 @@ DeepAgentsCheckpointLocation, Diagnostic, DiagnosticCode, + DeepAgentsCodeMessage, + DeepAgentsCodeMetadata, + DeepAgentsCodeTranscriptEnvelope, MetaRecord, NormalizationBounds, NormalizationErrorCode, @@ -37,6 +40,9 @@ "DeepAgentsCheckpointLocation", "Diagnostic", "DiagnosticCode", + "DeepAgentsCodeMessage", + "DeepAgentsCodeMetadata", + "DeepAgentsCodeTranscriptEnvelope", "MetaRecord", "NodeUnavailableError", "NormalizationBounds", diff --git a/python/src/trajectory/_types.py b/python/src/trajectory/_types.py index f86eac9..c932b9c 100644 --- a/python/src/trajectory/_types.py +++ b/python/src/trajectory/_types.py @@ -2,10 +2,12 @@ from typing import Literal, TypedDict, Union -TrajectorySource = Literal["claude-code", "codex", "letta", "openhands"] +TrajectorySource = Literal[ + "claude-code", "codex", "deepagents-code", "letta", "openhands" +] CheckpointTrajectorySource = Literal["deepagents"] AnyTrajectorySource = Literal[ - "claude-code", "codex", "letta", "openhands", "deepagents" + "claude-code", "codex", "deepagents-code", "letta", "openhands", "deepagents" ] ToolResultTruncationStrategy = Literal["head", "head-tail"] DiagnosticCode = Literal[ @@ -14,6 +16,7 @@ "injected_context_dropped", "noise_record_dropped", "sidechain_record_dropped", + "system_message_dropped", "tool_call_id_synthesized", "duplicate_tool_call_id", "orphan_tool_result", @@ -84,6 +87,34 @@ class DeepAgentsCheckpointInput(_NormalizeInputOptional): NormalizeRequest = Union[NormalizeInput, DeepAgentsCheckpointInput] +class _DeepAgentsCodeMessageOptional(TypedDict, total=False): + timestamp: str + + +class DeepAgentsCodeMessage(_DeepAgentsCodeMessageOptional): + message: dict[str, object] + + +class DeepAgentsCodeMetadata(TypedDict, total=False): + cwd: str + git_branch: str + agent_name: str + created_at: str + updated_at: str + + +class _DeepAgentsCodeTranscriptEnvelopeOptional(TypedDict, total=False): + metadata: DeepAgentsCodeMetadata + + +class DeepAgentsCodeTranscriptEnvelope(_DeepAgentsCodeTranscriptEnvelopeOptional): + type: Literal["deepagents-code-thread"] + version: Literal[1] + thread_id: str + checkpoint_ns: str + messages: list[DeepAgentsCodeMessage] + + class _DiagnosticOptional(TypedDict, total=False): inputLine: int recordIndex: int diff --git a/python/src/trajectory/_vendor/trajectory-cli.mjs b/python/src/trajectory/_vendor/trajectory-cli.mjs index ecbe200..79a39de 100644 --- a/python/src/trajectory/_vendor/trajectory-cli.mjs +++ b/python/src/trajectory/_vendor/trajectory-cli.mjs @@ -393,6 +393,208 @@ class NormalizationError extends Error { } } +// src/adapters/deepagents-code.ts +var TRANSCRIPT_TYPE = "deepagents-code-thread"; +var TRANSCRIPT_VERSION = 1; +var SYNTHETIC_SYSTEM_PREFIX = "[SYSTEM]"; +var deepAgentsCodeAdapter = { + source: "deepagents-code", + decode(transcript) { + const envelope = parseEnvelope(transcript); + const events = []; + const diagnostics = []; + for (const entry of envelope.messages) { + decodeMessage(entry.message, parseTimestamp(entry.timestamp), events, diagnostics); + } + const metadata = isObject(envelope.metadata) ? envelope.metadata : {}; + const createdAt = parseTimestamp(metadata.created_at); + const updatedAt = parseTimestamp(metadata.updated_at); + return { + events, + context: { + source: "deepagents-code", + ...typeof metadata.cwd === "string" && metadata.cwd ? { cwd: metadata.cwd } : {}, + ...typeof metadata.git_branch === "string" && metadata.git_branch ? { gitBranch: metadata.git_branch } : {}, + ...createdAt ? { createdAt } : {}, + ...createdAt && updatedAt && updatedAt >= createdAt ? { + durationSeconds: (updatedAt.getTime() - createdAt.getTime()) / 1000 + } : {} + }, + diagnostics + }; + } +}; +function parseEnvelope(transcript) { + let parsed; + try { + parsed = JSON.parse(transcript); + } catch { + throw invalidEnvelope(); + } + if (!isObject(parsed) || parsed.type !== TRANSCRIPT_TYPE || parsed.version !== TRANSCRIPT_VERSION || typeof parsed.thread_id !== "string" || typeof parsed.checkpoint_ns !== "string" || !Array.isArray(parsed.messages) || !parsed.messages.every((entry) => isObject(entry) && isObject(entry.message) && (entry.timestamp === undefined || typeof entry.timestamp === "string")) || parsed.metadata !== undefined && !isObject(parsed.metadata)) { + throw invalidEnvelope(); + } + return parsed; +} +function invalidEnvelope() { + return new NormalizationError("invalid_input", `Deep Agents Code transcript must be a version ${TRANSCRIPT_VERSION} ${JSON.stringify(TRANSCRIPT_TYPE)} JSON envelope.`); +} +function decodeMessage(message, timestamp, events, diagnostics) { + const type = messageType(message); + if (type === "remove") + return; + if (type === "system") { + diagnostics.push({ + code: "system_message_dropped", + message: "Dropped a Deep Agents Code system message because trajectory-v1 has no system role." + }); + return; + } + const content = messageText(message.content); + if (type === "human") { + if (content.startsWith(SYNTHETIC_SYSTEM_PREFIX)) { + diagnostics.push({ + code: "system_message_dropped", + message: "Dropped a synthetic Deep Agents Code system notification." + }); + } else if (content) { + events.push({ + type: "message", + role: "user", + content, + ...timestamp ? { timestamp } : {} + }); + } + return; + } + if (type === "tool" || type === "function") { + const callId = typeof message.tool_call_id === "string" && message.tool_call_id ? message.tool_call_id : undefined; + events.push({ + type: "tool_result", + content, + ...callId ? { callId } : {}, + ...timestamp ? { timestamp } : {} + }); + return; + } + if (type !== "ai") + return; + const model = messageModel(message); + const reasoning = messageReasoning(message); + if (reasoning) { + events.push({ + type: "reasoning", + content: reasoning, + ...model ? { model } : {}, + ...timestamp ? { timestamp } : {} + }); + } + if (content) { + events.push({ + type: "message", + role: "assistant", + content, + ...model ? { model } : {}, + ...timestamp ? { timestamp } : {} + }); + } + for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { + if (!isObject(call)) + continue; + events.push({ + type: "tool_call", + args: toolArguments(call.args), + ...typeof call.id === "string" && call.id ? { id: call.id } : {}, + ...typeof call.name === "string" && call.name ? { name: call.name } : {}, + ...model ? { model } : {}, + ...timestamp ? { timestamp } : {} + }); + } +} +function messageType(message) { + if (typeof message.type === "string") + return message.type; + if (typeof message.role === "string") { + if (message.role === "user") + return "human"; + if (message.role === "assistant") + return "ai"; + return message.role; + } + const className = message.__langgraph_class; + if (typeof className !== "string") + return; + if (className.startsWith("HumanMessage")) + return "human"; + if (className.startsWith("AIMessage")) + return "ai"; + if (className.startsWith("ToolMessage")) + return "tool"; + if (className.startsWith("SystemMessage")) + return "system"; + if (className.startsWith("FunctionMessage")) + return "function"; + if (className === "RemoveMessage") + return "remove"; + return; +} +function messageText(content) { + if (typeof content === "string") + return content; + if (!Array.isArray(content)) { + return content == null ? "" : jsonString({ content }); + } + const parts = []; + for (const part of content) { + if (typeof part === "string") { + parts.push(part); + continue; + } + if (!isObject(part)) + continue; + if (part.type === "text" || part.type === "input_text" || part.type === "output_text") { + if (typeof part.text === "string") + parts.push(part.text); + } else if (part.type === "image" || part.type === "image_url") { + parts.push("[image]"); + } + } + return parts.join(` +`); +} +function messageReasoning(message) { + const additional = isObject(message.additional_kwargs) ? message.additional_kwargs : {}; + if (typeof additional.reasoning_content === "string") { + return additional.reasoning_content; + } + const parts = []; + for (const part of Array.isArray(message.content) ? message.content : []) { + if (!isObject(part)) + continue; + if (part.type === "reasoning" || part.type === "thinking") { + const text = typeof part.reasoning === "string" ? part.reasoning : typeof part.thinking === "string" ? part.thinking : part.text; + if (typeof text === "string") + parts.push(text); + } + } + return parts.join(` +`); +} +function messageModel(message) { + const response = isObject(message.response_metadata) ? message.response_metadata : {}; + const additional = isObject(message.additional_kwargs) ? message.additional_kwargs : {}; + for (const value of [response.model_name, response.model, additional.model]) { + if (typeof value === "string" && value) + return value; + } + return; +} +function toolArguments(value) { + if (typeof value === "string" && value) + return value; + return jsonString(value); +} + // src/adapters/letta.ts var lettaAdapter = { source: "letta", @@ -440,7 +642,7 @@ var lettaAdapter = { for (const call of messageToolCalls(message)) { events.push({ type: "tool_call", - args: toolArguments(call.arguments), + args: toolArguments2(call.arguments), ...typeof call.tool_call_id === "string" && call.tool_call_id ? { id: call.tool_call_id } : {}, ...typeof call.name === "string" && call.name ? { name: call.name } : {}, ...timestamp ? { timestamp } : {} @@ -608,7 +810,7 @@ function decodeLocalMessage(message, entryTimestamp, events) { } else if (part.type === "toolCall") { events.push({ type: "tool_call", - args: toolArguments(part.arguments), + args: toolArguments2(part.arguments), ...typeof part.id === "string" && part.id ? { id: part.id } : {}, ...typeof part.name === "string" && part.name ? { name: part.name } : {}, ...timestamp ? { timestamp } : {}, @@ -636,7 +838,7 @@ function messageTimestamp(message) { const metadata = isObject(message.metadata) ? message.metadata : {}; return parseTimestamp(metadata.created_at) ?? parseTimestamp(message.date) ?? parseTimestamp(message.timestamp); } -function toolArguments(value) { +function toolArguments2(value) { if (typeof value === "string" && value) return value; return jsonString(value); @@ -873,17 +1075,17 @@ function resolveBounds(bounds) { return copyDefaults(); assertObject(bounds, "bounds"); assertKnownKeys(bounds, ["toolArguments", "toolResults"], "bounds"); - const toolArguments2 = bounds.toolArguments; - if (toolArguments2 !== undefined) { - assertObject(toolArguments2, "bounds.toolArguments"); - assertKnownKeys(toolArguments2, ["maxCharacters"], "bounds.toolArguments"); + const toolArguments3 = bounds.toolArguments; + if (toolArguments3 !== undefined) { + assertObject(toolArguments3, "bounds.toolArguments"); + assertKnownKeys(toolArguments3, ["maxCharacters"], "bounds.toolArguments"); } const toolResults = bounds.toolResults; if (toolResults !== undefined) { assertObject(toolResults, "bounds.toolResults"); assertKnownKeys(toolResults, ["maxCharacters", "strategy"], "bounds.toolResults"); } - const argumentLimit = resolveLimit(toolArguments2?.maxCharacters, DEFAULT_NORMALIZATION_BOUNDS.toolArguments.maxCharacters, "bounds.toolArguments.maxCharacters"); + const argumentLimit = resolveLimit(toolArguments3?.maxCharacters, DEFAULT_NORMALIZATION_BOUNDS.toolArguments.maxCharacters, "bounds.toolArguments.maxCharacters"); if (argumentLimit !== null && argumentLimit < 2) { throw invalidBounds("bounds.toolArguments.maxCharacters must be at least 2 so arguments can remain a JSON object."); } @@ -1691,6 +1893,7 @@ function isToolData(value) { var ADAPTERS = { "claude-code": claudeCodeAdapter, codex: codexAdapter, + "deepagents-code": deepAgentsCodeAdapter, letta: lettaAdapter, openhands: openHandsAdapter }; diff --git a/python/tests/test_wrapper.py b/python/tests/test_wrapper.py index fb50d26..a0e4612 100644 --- a/python/tests/test_wrapper.py +++ b/python/tests/test_wrapper.py @@ -25,6 +25,8 @@ ("claude-code", "claude-code/cleanup", "input.jsonl"), ("codex", "codex/tool-calls", "input.jsonl"), ("codex", "codex/cleanup", "input.jsonl"), + ("deepagents-code", "deepagents-code/tool-calls", "input.json"), + ("deepagents-code", "deepagents-code/cleanup", "input.json"), ("letta", "letta/tool-call", "input.json"), ("letta", "letta/cleanup", "input.json"), ("letta", "letta/local-v3", "input.jsonl"), diff --git a/src/adapters/deepagents-code.ts b/src/adapters/deepagents-code.ts new file mode 100644 index 0000000..2348d8c --- /dev/null +++ b/src/adapters/deepagents-code.ts @@ -0,0 +1,279 @@ +import type { + DecodedEvent, + DecodedSession, + SourceAdapter, +} from "../internal.js"; +import type { Diagnostic } from "../types.js"; +import { NormalizationError } from "../types.js"; +import { isObject, jsonString, parseTimestamp } from "./shared.js"; + +const TRANSCRIPT_TYPE = "deepagents-code-thread"; +const TRANSCRIPT_VERSION = 1; +const SYNTHETIC_SYSTEM_PREFIX = "[SYSTEM]"; + +export interface DeepAgentsCodeMessage { + message: Record; + timestamp?: string; +} + +export interface DeepAgentsCodeMetadata { + cwd?: string; + git_branch?: string; + agent_name?: string; + created_at?: string; + updated_at?: string; +} + +export interface DeepAgentsCodeTranscriptEnvelope { + type: typeof TRANSCRIPT_TYPE; + version: typeof TRANSCRIPT_VERSION; + thread_id: string; + checkpoint_ns: string; + messages: DeepAgentsCodeMessage[]; + metadata?: DeepAgentsCodeMetadata; +} + +/** Decode a safe JSON envelope containing reconstructed message dictionaries. */ +export const deepAgentsCodeAdapter: SourceAdapter = { + source: "deepagents-code", + + decode(transcript: string): DecodedSession { + const envelope = parseEnvelope(transcript); + const events: DecodedEvent[] = []; + const diagnostics: Diagnostic[] = []; + + for (const entry of envelope.messages) { + decodeMessage( + entry.message, + parseTimestamp(entry.timestamp), + events, + diagnostics, + ); + } + + const metadata = isObject(envelope.metadata) ? envelope.metadata : {}; + const createdAt = parseTimestamp(metadata.created_at); + const updatedAt = parseTimestamp(metadata.updated_at); + return { + events, + context: { + source: "deepagents-code", + ...(typeof metadata.cwd === "string" && metadata.cwd + ? { cwd: metadata.cwd } + : {}), + ...(typeof metadata.git_branch === "string" && metadata.git_branch + ? { gitBranch: metadata.git_branch } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(createdAt && updatedAt && updatedAt >= createdAt + ? { + durationSeconds: + (updatedAt.getTime() - createdAt.getTime()) / 1_000, + } + : {}), + }, + diagnostics, + }; + }, +}; + +function parseEnvelope(transcript: string): DeepAgentsCodeTranscriptEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse(transcript); + } catch { + throw invalidEnvelope(); + } + if ( + !isObject(parsed) || + parsed.type !== TRANSCRIPT_TYPE || + parsed.version !== TRANSCRIPT_VERSION || + typeof parsed.thread_id !== "string" || + typeof parsed.checkpoint_ns !== "string" || + !Array.isArray(parsed.messages) || + !parsed.messages.every( + (entry) => + isObject(entry) && + isObject(entry.message) && + (entry.timestamp === undefined || typeof entry.timestamp === "string"), + ) || + (parsed.metadata !== undefined && !isObject(parsed.metadata)) + ) { + throw invalidEnvelope(); + } + return parsed as unknown as DeepAgentsCodeTranscriptEnvelope; +} + +function invalidEnvelope(): NormalizationError { + return new NormalizationError( + "invalid_input", + `Deep Agents Code transcript must be a version ${TRANSCRIPT_VERSION} ${JSON.stringify(TRANSCRIPT_TYPE)} JSON envelope.`, + ); +} + +function decodeMessage( + message: Record, + timestamp: Date | undefined, + events: DecodedEvent[], + diagnostics: Diagnostic[], +): void { + const type = messageType(message); + if (type === "remove") return; + + if (type === "system") { + diagnostics.push({ + code: "system_message_dropped", + message: + "Dropped a Deep Agents Code system message because trajectory-v1 has no system role.", + }); + return; + } + + const content = messageText(message.content); + if (type === "human") { + if (content.startsWith(SYNTHETIC_SYSTEM_PREFIX)) { + diagnostics.push({ + code: "system_message_dropped", + message: "Dropped a synthetic Deep Agents Code system notification.", + }); + } else if (content) { + events.push({ + type: "message", + role: "user", + content, + ...(timestamp ? { timestamp } : {}), + }); + } + return; + } + + if (type === "tool" || type === "function") { + const callId = + typeof message.tool_call_id === "string" && message.tool_call_id + ? message.tool_call_id + : undefined; + events.push({ + type: "tool_result", + content, + ...(callId ? { callId } : {}), + ...(timestamp ? { timestamp } : {}), + }); + return; + } + + if (type !== "ai") return; + const model = messageModel(message); + const reasoning = messageReasoning(message); + if (reasoning) { + events.push({ + type: "reasoning", + content: reasoning, + ...(model ? { model } : {}), + ...(timestamp ? { timestamp } : {}), + }); + } + if (content) { + events.push({ + type: "message", + role: "assistant", + content, + ...(model ? { model } : {}), + ...(timestamp ? { timestamp } : {}), + }); + } + for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { + if (!isObject(call)) continue; + events.push({ + type: "tool_call", + args: toolArguments(call.args), + ...(typeof call.id === "string" && call.id ? { id: call.id } : {}), + ...(typeof call.name === "string" && call.name ? { name: call.name } : {}), + ...(model ? { model } : {}), + ...(timestamp ? { timestamp } : {}), + }); + } +} + +function messageType(message: Record): string | undefined { + if (typeof message.type === "string") return message.type; + if (typeof message.role === "string") { + if (message.role === "user") return "human"; + if (message.role === "assistant") return "ai"; + return message.role; + } + const className = message.__langgraph_class; + if (typeof className !== "string") return undefined; + if (className.startsWith("HumanMessage")) return "human"; + if (className.startsWith("AIMessage")) return "ai"; + if (className.startsWith("ToolMessage")) return "tool"; + if (className.startsWith("SystemMessage")) return "system"; + if (className.startsWith("FunctionMessage")) return "function"; + if (className === "RemoveMessage") return "remove"; + return undefined; +} + +function messageText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) { + return content == null ? "" : jsonString({ content }); + } + const parts: string[] = []; + for (const part of content) { + if (typeof part === "string") { + parts.push(part); + continue; + } + if (!isObject(part)) continue; + if ( + part.type === "text" || + part.type === "input_text" || + part.type === "output_text" + ) { + if (typeof part.text === "string") parts.push(part.text); + } else if (part.type === "image" || part.type === "image_url") { + parts.push("[image]"); + } + } + return parts.join("\n"); +} + +function messageReasoning(message: Record): string { + const additional = isObject(message.additional_kwargs) + ? message.additional_kwargs + : {}; + if (typeof additional.reasoning_content === "string") { + return additional.reasoning_content; + } + const parts: string[] = []; + for (const part of Array.isArray(message.content) ? message.content : []) { + if (!isObject(part)) continue; + if (part.type === "reasoning" || part.type === "thinking") { + const text = + typeof part.reasoning === "string" + ? part.reasoning + : typeof part.thinking === "string" + ? part.thinking + : part.text; + if (typeof text === "string") parts.push(text); + } + } + return parts.join("\n"); +} + +function messageModel(message: Record): string | undefined { + const response = isObject(message.response_metadata) + ? message.response_metadata + : {}; + const additional = isObject(message.additional_kwargs) + ? message.additional_kwargs + : {}; + for (const value of [response.model_name, response.model, additional.model]) { + if (typeof value === "string" && value) return value; + } + return undefined; +} + +function toolArguments(value: unknown): string { + if (typeof value === "string" && value) return value; + return jsonString(value); +} diff --git a/src/index.ts b/src/index.ts index 9182203..25aaff4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { claudeCodeAdapter } from "./adapters/claude-code.js"; import { codexAdapter } from "./adapters/codex.js"; +import { deepAgentsCodeAdapter } from "./adapters/deepagents-code.js"; import { lettaAdapter } from "./adapters/letta.js"; import { openHandsAdapter } from "./adapters/openhands.js"; import { decodeDeepAgentsCheckpoint } from "./adapters/deepagents.js"; @@ -18,6 +19,7 @@ import { NormalizationError } from "./types.js"; const ADAPTERS: Record = { "claude-code": claudeCodeAdapter, codex: codexAdapter, + "deepagents-code": deepAgentsCodeAdapter, letta: lettaAdapter, openhands: openHandsAdapter, }; @@ -68,6 +70,11 @@ export async function normalizeCheckpoint( export { loadDeepAgentsCheckpoint } from "./deepagents-checkpoint.js"; export { DEFAULT_NORMALIZATION_BOUNDS } from "./bounds.js"; +export type { + DeepAgentsCodeMessage, + DeepAgentsCodeMetadata, + DeepAgentsCodeTranscriptEnvelope, +} from "./adapters/deepagents-code.js"; export { validateTranscript } from "./validate.js"; export { NormalizationError, diff --git a/src/types.ts b/src/types.ts index 199e9f1..968c004 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export type TrajectorySource = | "claude-code" | "codex" + | "deepagents-code" | "letta" | "openhands"; @@ -103,6 +104,7 @@ export type DiagnosticCode = | "injected_context_dropped" | "noise_record_dropped" | "sidechain_record_dropped" + | "system_message_dropped" | "tool_call_id_synthesized" | "duplicate_tool_call_id" | "orphan_tool_result" diff --git a/test/normalize.test.ts b/test/normalize.test.ts index 5b72570..f51da10 100644 --- a/test/normalize.test.ts +++ b/test/normalize.test.ts @@ -10,17 +10,23 @@ import { import type { NormalizeResult, TrajectorySource } from "../src/index.js"; const fixtures = [ - { source: "claude-code", name: "claude-code/tool-call" }, - { source: "claude-code", name: "claude-code/cleanup" }, - { source: "codex", name: "codex/tool-calls" }, - { source: "codex", name: "codex/cleanup" }, - { source: "letta", name: "letta/tool-call" }, - { source: "letta", name: "letta/cleanup" }, - { source: "letta", name: "letta/local-v3" }, - { source: "letta", name: "letta/local-legacy" }, - { source: "openhands", name: "openhands/tool-calls" }, - { source: "openhands", name: "openhands/cleanup" }, -] as const satisfies ReadonlyArray<{ source: TrajectorySource; name: string }>; + { source: "claude-code", name: "claude-code/tool-call", input: "input.jsonl" }, + { source: "claude-code", name: "claude-code/cleanup", input: "input.jsonl" }, + { source: "codex", name: "codex/tool-calls", input: "input.jsonl" }, + { source: "codex", name: "codex/cleanup", input: "input.jsonl" }, + { source: "deepagents-code", name: "deepagents-code/tool-calls", input: "input.json" }, + { source: "deepagents-code", name: "deepagents-code/cleanup", input: "input.json" }, + { source: "letta", name: "letta/tool-call", input: "input.json" }, + { source: "letta", name: "letta/cleanup", input: "input.json" }, + { source: "letta", name: "letta/local-v3", input: "input.jsonl" }, + { source: "letta", name: "letta/local-legacy", input: "input.jsonl" }, + { source: "openhands", name: "openhands/tool-calls", input: "input.json" }, + { source: "openhands", name: "openhands/cleanup", input: "input.json" }, +] as const satisfies ReadonlyArray<{ + source: TrajectorySource; + name: string; + input: string; +}>; const schema = JSON.parse( readFileSync( @@ -33,13 +39,7 @@ const validateSchema = new Ajv2020().compile(schema); describe("golden fixtures", () => { for (const fixture of fixtures) { test(fixture.name, () => { - const input = fixtureText( - fixture.name, - fixture.source === "openhands" || - (fixture.source === "letta" && !fixture.name.startsWith("letta/local-")) - ? "input.json" - : "input.jsonl", - ); + const input = fixtureText(fixture.name, fixture.input); const expected = JSON.parse( fixtureText(fixture.name, "expected.json"), ) as NormalizeResult; @@ -104,6 +104,36 @@ describe("public API", () => { ).toThrow(expect.objectContaining({ code: "invalid_input" })); }); + test("rejects an invalid Deep Agents Code envelope", () => { + expect(() => + normalizeTranscript({ + source: "deepagents-code", + transcript: JSON.stringify({ + type: "deepagents-code-thread", + version: 2, + thread_id: "thread-1", + checkpoint_ns: "", + messages: [], + }), + }), + ).toThrow(expect.objectContaining({ code: "invalid_input" })); + }); + + test("rejects unsafe non-dictionary Deep Agents Code messages", () => { + expect(() => + normalizeTranscript({ + source: "deepagents-code", + transcript: JSON.stringify({ + type: "deepagents-code-thread", + version: 1, + thread_id: "thread-1", + checkpoint_ns: "", + messages: [{ message: "not decoded" }], + }), + }), + ).toThrow(expect.objectContaining({ code: "invalid_input" })); + }); + test("rejects a non-flat Letta document shape", () => { expect(() => normalizeTranscript({ From 69c7cbe84bf5f982392f7129c81f84f2c7ac3c33 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Sat, 11 Jul 2026 15:52:21 -0700 Subject: [PATCH 2/2] Add Deep Agents Code local adapter --- PARITY.md | 16 ++- README.md | 76 +++++++++--- python/src/trajectory/__init__.py | 10 +- python/src/trajectory/_client.py | 30 +++++ python/tests/test_wrapper.py | 107 +++++++++++++++++ src/index.ts | 70 +++++++++++ src/types.ts | 12 ++ test/deepagents.test.ts | 192 ++++++++++++++++++++++++++++++ 8 files changed, 492 insertions(+), 21 deletions(-) diff --git a/PARITY.md b/PARITY.md index d91d5a1..64598b0 100644 --- a/PARITY.md +++ b/PARITY.md @@ -81,7 +81,15 @@ An interoperability probe confirmed that a database written by the official Python `SqliteSaver` `3.1.0` is not readable by the official JavaScript `SqliteSaver` `1.0.3`: the JavaScript implementation rejects Python's `msgpack` serialization tag. Consequently, this package does not decode the -SQLite store or its blobs. The `deepagents-code` adapter normalizes a versioned, -plain-JSON envelope after the shared official-Python helper performs thread -selection, namespace-aware reconstruction, and reducer replay. Deterministic -fixtures cover the envelope boundary. +SQLite store or its blobs. `normalizeDeepAgentsCode` is a thin fixed-path wrapper +over the generic official-Python checkpoint adapter: it selects an explicit +thread from `~/.deepagents/.state/sessions.db`, forwards namespace/checkpoint +options and bounds, and retags only the leading metadata source. Integration +tests copy the generic Python-generated fixture beneath a temporary redirected +`HOME`; they cover latest and explicit checkpoint selection, non-root +namespaces, default-path resolution, and metadata retagging without touching a +user database. + +The separate `deepagents-code` transcript adapter continues to normalize a +versioned plain-JSON envelope when callers already have reconstructed message +dictionaries. It performs no local database access. diff --git a/README.md b/README.md index b3950c7..09c737f 100644 --- a/README.md +++ b/README.md @@ -113,15 +113,61 @@ and is empty when the transcript required no recoverable cleanup. | --- | --- | --- | | `claude-code` | Native Claude Code JSONL | `claude-code` | | `codex` | Native Codex rollout JSONL | `codex` | -| `deepagents-code` | Version 1 safe JSON envelope of a reconstructed Deep Agents Code thread | `deepagents-code` | +| `deepagents-code` | Local default database via `normalizeDeepAgentsCode`, or a version 1 safe JSON envelope via `normalizeTranscript` | `deepagents-code` | | `letta` | Cloud/API message array or local conversation JSONL (legacy and v3) | `letta` | | `openhands` | JSON event array or an events-API `{ "items": [...] }` envelope | `openhands` | | `deepagents` | User-supplied Python LangGraph `SqliteSaver` database plus `threadId` | `deepagents` | -### Deep Agents Code envelopes +### Deep Agents Code + +Deep Agents Code stores its LangGraph sessions at +`~/.deepagents/.state/sessions.db`. Normalize one explicitly selected thread +with the async convenience wrapper: + +```ts +import { + DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH, + normalizeDeepAgentsCode, +} from "@letta-ai/trajectory"; + +const result = await normalizeDeepAgentsCode({ + threadId: "thread-123", + checkpointNamespace: "", // optional; root namespace by default + // checkpointId: "...", // optional; latest checkpoint by default + // pythonExecutable: "/path/to/venv/bin/python", +}); +``` + +`threadId` is always required; the wrapper does not list or guess threads and +does not accept a custom database path. The exported display constant is +`DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH` (`~/.deepagents/.state/sessions.db`). +The actual home directory is resolved when the function is called. Internally, +the wrapper delegates to `normalizeCheckpoint({ source: "deepagents", ... })`, +so checkpoint selection, serializer decoding, parent traversal, and reducer +semantics stay in the shared official-Python helper. Only the leading +`meta.source` is changed from `deepagents` to `deepagents-code`. + +The Python wrapper uses the same fixed path and its current Python interpreter +by default: + +```python +from trajectory import normalize_deepagents_code + +result = normalize_deepagents_code( + thread_id="thread-123", + checkpoint_namespace="", # optional + checkpoint_id=None, # optional; latest when omitted +) +``` + +Install the Python `deepagents` extra, or select a Python environment containing +`langgraph` and `langgraph-checkpoint-sqlite`, as described below. + +#### Safe envelope transcripts `deepagents-code` accepts a JSON string containing a safe, already-decoded -thread envelope: +thread envelope through `normalizeTranscript`. This is separate from the local +database convenience wrapper: ```json { @@ -175,15 +221,12 @@ timestamps are preserved. System messages are omitted with a diagnostic because trajectory-v1 has no system role. Removal records are expected to have already been applied while reconstructing checkpoint state and are ignored defensively. -This package does **not** read `~/.deepagents/.state/sessions.db` or deserialize -LangGraph blobs. Deep Agents Code uses the Python LangGraph SQLite checkpointer -and MessagePack serializer; the official JavaScript SQLite checkpointer cannot -read that representation. Thread discovery, selection, checkpoint-namespace -handling, and reducer replay will therefore delegate to the upcoming shared -official-Python helper, which will emit this envelope. Until that helper is -available, callers must supply an envelope from a trusted exporter. Do not use -ad hoc object construction, pickle, or executable deserialization on checkpoint -blobs. +The envelope adapter performs no database access or blob deserialization. +Callers using this lower-level form must supply an envelope from a trusted +exporter. Do not use ad hoc object construction, pickle, or executable +deserialization on checkpoint blobs. For the standard Deep Agents Code local +database, prefer `normalizeDeepAgentsCode`, which delegates to the shared +official-Python helper instead. Letta messages use native `message_type` values such as `user_message`, `reasoning_message`, `assistant_message`, `tool_call_message`, @@ -266,10 +309,11 @@ result = normalize_checkpoint( ) ``` -`loadDeepAgentsCheckpoint(location)` is also exported for integrations such as -Deep Agents Code that need to discover a location before reusing the same -decoder. `FilesystemBackend` is unrelated to this trajectory: it persists -agent-created files, not the LangGraph message/checkpoint state. +`loadDeepAgentsCheckpoint(location)` is also exported for integrations that +need decoded checkpoint data before normalization. `normalizeDeepAgentsCode` +is the fixed-path convenience layer over this generic implementation. +`FilesystemBackend` is unrelated to this trajectory: it persists agent-created +files, not the LangGraph message/checkpoint state. An unknown source, an invalid source-level container, or a transcript that cannot form a valid trajectory throws `NormalizationError`. Recoverable diff --git a/python/src/trajectory/__init__.py b/python/src/trajectory/__init__.py index 66912d9..b3f4bd4 100644 --- a/python/src/trajectory/__init__.py +++ b/python/src/trajectory/__init__.py @@ -1,6 +1,12 @@ """Normalize native agent transcripts using the canonical trajectory runtime.""" -from ._client import normalize_checkpoint, normalize_many, normalize_transcript +from ._client import ( + DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH, + normalize_checkpoint, + normalize_deepagents_code, + normalize_many, + normalize_transcript, +) from ._errors import NodeUnavailableError, NormalizationError, TrajectoryRuntimeError from ._types import ( AssistantMessageRecord, @@ -40,6 +46,7 @@ "DeepAgentsCheckpointLocation", "Diagnostic", "DiagnosticCode", + "DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH", "DeepAgentsCodeMessage", "DeepAgentsCodeMetadata", "DeepAgentsCodeTranscriptEnvelope", @@ -63,6 +70,7 @@ "UserRecord", "normalize_many", "normalize_checkpoint", + "normalize_deepagents_code", "normalize_transcript", ] diff --git a/python/src/trajectory/_client.py b/python/src/trajectory/_client.py index 33be40d..6596191 100644 --- a/python/src/trajectory/_client.py +++ b/python/src/trajectory/_client.py @@ -25,6 +25,7 @@ _PROTOCOL_VERSION = 1 _MINIMUM_NODE_MAJOR = 20 _CLI_PATH = Path(__file__).parent / "_vendor" / "trajectory-cli.mjs" +DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH = "~/.deepagents/.state/sessions.db" def normalize_transcript( @@ -69,6 +70,35 @@ def normalize_checkpoint( return normalize_many([request])[0] +def normalize_deepagents_code( + *, + thread_id: str, + checkpoint_namespace: str = "", + checkpoint_id: str | None = None, + bounds: NormalizationBounds | None = None, + python_executable: str | None = None, +) -> NormalizeResult: + """Normalize one explicitly selected Deep Agents Code local thread.""" + + if not isinstance(thread_id, str) or not thread_id: + raise NormalizationError( + "invalid_input", "Deep Agents Code thread_id must be a non-empty string." + ) + result = normalize_checkpoint( + path=Path.home() / ".deepagents" / ".state" / "sessions.db", + thread_id=thread_id, + checkpoint_namespace=checkpoint_namespace, + checkpoint_id=checkpoint_id, + bounds=bounds, + python_executable=python_executable, + ) + meta, *records = result["records"] + return { + "records": [{**meta, "source": "deepagents-code"}, *records], + "diagnostics": result["diagnostics"], + } + + def normalize_many(inputs: Iterable[NormalizeRequest]) -> list[NormalizeResult]: """Normalize multiple transcript or checkpoint requests in one Node.js subprocess. diff --git a/python/tests/test_wrapper.py b/python/tests/test_wrapper.py index a0e4612..9662b87 100644 --- a/python/tests/test_wrapper.py +++ b/python/tests/test_wrapper.py @@ -1,5 +1,6 @@ import json import importlib.util +import os import shutil import tempfile import unittest @@ -8,9 +9,11 @@ import trajectory._client as client from trajectory import ( + DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH, NodeUnavailableError, NormalizationError, normalize_checkpoint, + normalize_deepagents_code, normalize_many, normalize_transcript, ) @@ -90,6 +93,66 @@ def test_requires_node_20_or_newer(self) -> None: finally: client._node_executable.cache_clear() + def test_deepagents_code_requires_explicit_thread_id(self) -> None: + with self.assertRaises(NormalizationError) as raised: + normalize_deepagents_code(thread_id="") + + self.assertEqual(raised.exception.code, "invalid_input") + + def test_deepagents_code_delegates_fixed_path_and_retags_meta(self) -> None: + generic = { + "records": [ + {"role": "meta", "source": "deepagents", "cwd": "/workspace"}, + { + "role": "user", + "content": "Hello", + "timestamp": "2026-01-02T03:04:05.000Z", + }, + { + "role": "assistant", + "content": "Hi", + "timestamp": "2026-01-02T03:04:06.000Z", + }, + ], + "diagnostics": [], + } + bounds = {"toolResults": {"maxCharacters": 20, "strategy": "head"}} + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) / "home" + database = home / ".deepagents" / ".state" / "sessions.db" + database.parent.mkdir(parents=True) + shutil.copyfile(ROOT / "fixtures/deepagents/checkpoint.db", database) + with patch.dict(os.environ, {"HOME": str(home)}): + with patch.object( + client, "normalize_checkpoint", return_value=generic + ) as delegated: + result = normalize_deepagents_code( + thread_id="thread-123", + checkpoint_namespace="sdk", + checkpoint_id="checkpoint-1", + bounds=bounds, # type: ignore[arg-type] + python_executable="/test/python", + ) + + delegated.assert_called_once_with( + path=database, + thread_id="thread-123", + checkpoint_namespace="sdk", + checkpoint_id="checkpoint-1", + bounds=bounds, + python_executable="/test/python", + ) + self.assertEqual( + result["records"][0], + { + "role": "meta", + "source": "deepagents-code", + "cwd": "/workspace", + }, + ) + self.assertEqual(result["records"][1:], generic["records"][1:]) + self.assertEqual(result["diagnostics"], generic["diagnostics"]) + @unittest.skipUnless(HAS_LANGGRAPH_SQLITE, "LangGraph SQLite extra not installed") def test_normalizes_deepagents_checkpoint_with_current_python(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -122,6 +185,50 @@ def test_normalizes_deepagents_checkpoint_with_current_python(self) -> None: self.assertEqual(result["records"][-1]["content"], "It is sunny and 22 C in Paris.") self.assertEqual(batch[0]["records"][1]["content"], "Other namespace") + @unittest.skipUnless(HAS_LANGGRAPH_SQLITE, "LangGraph SQLite extra not installed") + def test_normalizes_deepagents_code_from_default_local_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) / "home" + database = home / ".deepagents" / ".state" / "sessions.db" + database.parent.mkdir(parents=True) + shutil.copyfile(ROOT / "fixtures/deepagents/checkpoint.db", database) + with patch.dict(os.environ, {"HOME": str(home)}): + generic = normalize_checkpoint( + path=database, + thread_id="thread-123", + checkpoint_namespace="sdk", + ) + result = normalize_deepagents_code( + thread_id="thread-123", + checkpoint_namespace="sdk", + ) + historical = normalize_deepagents_code( + thread_id="thread-123", + checkpoint_namespace="sdk", + checkpoint_id="00000000-0000-6000-8000-000000000001", + ) + other = normalize_deepagents_code( + thread_id="thread-123", + checkpoint_namespace="other", + ) + + expected = dict(generic) + expected["records"] = [ + {**generic["records"][0], "source": "deepagents-code"}, + *generic["records"][1:], + ] + self.assertEqual( + DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH, + "~/.deepagents/.state/sessions.db", + ) + self.assertEqual(result, expected) + self.assertEqual(historical["records"][0]["source"], "deepagents-code") + self.assertNotEqual( + historical["records"][-1].get("content"), + "It is sunny and 22 C in Paris.", + ) + self.assertEqual(other["records"][1]["content"], "Other namespace") + if __name__ == "__main__": unittest.main() diff --git a/src/index.ts b/src/index.ts index 25aaff4..4540455 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; import { claudeCodeAdapter } from "./adapters/claude-code.js"; import { codexAdapter } from "./adapters/codex.js"; import { deepAgentsCodeAdapter } from "./adapters/deepagents-code.js"; @@ -10,6 +12,7 @@ import { loadDeepAgentsCheckpoint } from "./deepagents-checkpoint.js"; import type { SourceAdapter } from "./internal.js"; import type { DeepAgentsCheckpointInput, + NormalizeDeepAgentsCodeInput, NormalizeInput, NormalizeResult, TranscriptTrajectorySource, @@ -67,6 +70,72 @@ export async function normalizeCheckpoint( ); } +/** Display form of the fixed Deep Agents Code local checkpoint path. */ +export const DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH = + "~/.deepagents/.state/sessions.db"; + +/** Normalize one explicitly selected thread from Deep Agents Code's local store. */ +export async function normalizeDeepAgentsCode( + input: NormalizeDeepAgentsCodeInput, +): Promise { + if (!input || typeof input !== "object") { + throw new NormalizationError("invalid_input", "Input must be an object."); + } + if (typeof input.threadId !== "string" || !input.threadId) { + throw new NormalizationError( + "invalid_input", + "Deep Agents Code threadId must be a non-empty string.", + ); + } + + const result = await normalizeCheckpoint({ + source: "deepagents", + checkpoint: { + path: resolveDeepAgentsCodeDatabasePath(), + threadId: input.threadId, + ...(input.checkpointNamespace !== undefined + ? { checkpointNamespace: input.checkpointNamespace } + : {}), + ...(input.checkpointId !== undefined + ? { checkpointId: input.checkpointId } + : {}), + ...(input.pythonExecutable !== undefined + ? { pythonExecutable: input.pythonExecutable } + : {}), + }, + ...(input.bounds !== undefined ? { bounds: input.bounds } : {}), + }); + + const [meta, ...records] = result.records; + if (!meta || meta.role !== "meta") { + throw new NormalizationError( + "invalid_normalized_transcript", + "Deep Agents checkpoint normalization did not produce a leading meta record.", + ); + } + return { + records: [{ ...meta, source: "deepagents-code" }, ...records], + diagnostics: result.diagnostics, + }; +} + +function resolveDeepAgentsCodeDatabasePath(): string { + return join(resolveHomeDirectory(), ".deepagents", ".state", "sessions.db"); +} + +function resolveHomeDirectory(): string { + if (process.platform === "win32") { + const profile = process.env.USERPROFILE; + if (profile) return profile; + const drive = process.env.HOMEDRIVE; + const path = process.env.HOMEPATH; + if (drive && path) return `${drive}${path}`; + } else if (process.env.HOME) { + return process.env.HOME; + } + return homedir(); +} + export { loadDeepAgentsCheckpoint } from "./deepagents-checkpoint.js"; export { DEFAULT_NORMALIZATION_BOUNDS } from "./bounds.js"; @@ -98,6 +167,7 @@ export { type NormalizedRecord, type NormalizedTranscript, type NormalizeInput, + type NormalizeDeepAgentsCodeInput, type NormalizeResult, type ReasoningRecord, type ToolCall, diff --git a/src/types.ts b/src/types.ts index 968c004..2b2e0f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,18 @@ export interface DeepAgentsCheckpointInput { bounds?: NormalizationBounds; } +export interface NormalizeDeepAgentsCodeInput { + /** Deep Agents Code LangGraph thread_id. */ + threadId: string; + /** LangGraph checkpoint_ns. Defaults to the root namespace (empty string). */ + checkpointNamespace?: string; + /** Select one checkpoint. When omitted, selects the latest checkpoint. */ + checkpointId?: string; + /** Python interpreter containing LangGraph and langgraph-checkpoint-sqlite. */ + pythonExecutable?: string; + bounds?: NormalizationBounds; +} + export interface DeepAgentsToolCall { id?: string; name?: string; diff --git a/test/deepagents.test.ts b/test/deepagents.test.ts index fc147eb..e6b63c5 100644 --- a/test/deepagents.test.ts +++ b/test/deepagents.test.ts @@ -2,7 +2,9 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { chmodSync, copyFileSync, + mkdirSync, mkdtempSync, + readFileSync, rmSync, writeFileSync, } from "node:fs"; @@ -11,8 +13,10 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { + DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH, loadDeepAgentsCheckpoint, normalizeCheckpoint, + normalizeDeepAgentsCode, } from "../src/index.js"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); @@ -21,11 +25,188 @@ const PYTHON = findLangGraphPython(); const integrationTest = PYTHON ? test : test.skip; let temporaryDirectory = ""; let databasePath = ""; +let deepAgentsCodeHome = ""; beforeAll(() => { temporaryDirectory = mkdtempSync(join(tmpdir(), "trajectory-deepagents-")); databasePath = join(temporaryDirectory, "checkpoint.db"); copyFileSync(FIXTURE, databasePath); + deepAgentsCodeHome = join(temporaryDirectory, "home"); + const stateDirectory = join(deepAgentsCodeHome, ".deepagents", ".state"); + mkdirSync(stateDirectory, { recursive: true }); + copyFileSync(FIXTURE, join(stateDirectory, "sessions.db")); +}); + +describe("Deep Agents Code local wrapper", () => { + test("resolves HOME at call time and delegates checkpoint selection", async () => { + const fakePython = join(temporaryDirectory, "deepagents-code-fake-python"); + const capturedRequest = join(temporaryDirectory, "deepagents-code-request.json"); + const response = JSON.stringify({ + ok: true, + data: { + checkpointId: "checkpoint-selected", + checkpointNamespace: "subagent", + checkpointTimestamp: "2026-01-02T03:04:05Z", + messages: [ + { + role: "human", + content: "Delegated user", + timestamp: "2026-01-02T03:04:05Z", + }, + { + role: "ai", + content: "Delegated response", + reasoning: [], + toolCalls: [], + timestamp: "2026-01-02T03:04:06Z", + }, + ], + }, + }); + writeFileSync( + fakePython, + `#!/bin/sh\ncat > "$TRAJECTORY_DEEPAGENTS_CODE_CAPTURE"\nprintf '%s' '${response}'\n`, + ); + chmodSync(fakePython, 0o755); + const originalCapture = process.env.TRAJECTORY_DEEPAGENTS_CODE_CAPTURE; + process.env.TRAJECTORY_DEEPAGENTS_CODE_CAPTURE = capturedRequest; + let result: Awaited>; + try { + result = await withDeepAgentsCodeHome(() => + normalizeDeepAgentsCode({ + threadId: "thread-selected", + checkpointNamespace: "subagent", + checkpointId: "checkpoint-selected", + pythonExecutable: fakePython, + }), + ); + } finally { + if (originalCapture === undefined) { + delete process.env.TRAJECTORY_DEEPAGENTS_CODE_CAPTURE; + } else { + process.env.TRAJECTORY_DEEPAGENTS_CODE_CAPTURE = originalCapture; + } + } + + expect( + JSON.parse(readFileSync(capturedRequest, "utf8")), + ).toEqual({ + path: join(deepAgentsCodeHome, ".deepagents", ".state", "sessions.db"), + threadId: "thread-selected", + checkpointNamespace: "subagent", + checkpointId: "checkpoint-selected", + }); + expect(result.records).toEqual([ + { role: "meta", source: "deepagents-code" }, + { + role: "user", + content: "Delegated user", + timestamp: "2026-01-02T03:04:05.000Z", + }, + { + role: "assistant", + content: "Delegated response", + timestamp: "2026-01-02T03:04:06.000Z", + }, + ]); + expect(result.diagnostics).toEqual([]); + }); + + integrationTest("uses the fixed default path and retags only meta source", async () => { + const generic = await normalizeCheckpoint({ + source: "deepagents", + checkpoint: { + path: databasePath, + threadId: "thread-123", + checkpointNamespace: "sdk", + pythonExecutable: PYTHON!, + }, + }); + const result = await withDeepAgentsCodeHome(() => + normalizeDeepAgentsCode({ + threadId: "thread-123", + checkpointNamespace: "sdk", + pythonExecutable: PYTHON!, + }), + ); + const genericMeta = generic.records[0]; + if (!genericMeta || genericMeta.role !== "meta") { + throw new Error("Generic checkpoint fixture did not produce metadata."); + } + + expect(DEEP_AGENTS_CODE_DEFAULT_DATABASE_PATH).toBe( + "~/.deepagents/.state/sessions.db", + ); + expect(result).toEqual({ + ...generic, + records: [ + { ...genericMeta, source: "deepagents-code" }, + ...generic.records.slice(1), + ], + }); + }); + + integrationTest("forwards explicit checkpoint selection and bounds", async () => { + const result = await withDeepAgentsCodeHome(() => + normalizeDeepAgentsCode({ + threadId: "thread-123", + checkpointNamespace: "sdk", + checkpointId: "00000000-0000-6000-8000-000000000001", + pythonExecutable: PYTHON!, + bounds: { toolResults: { maxCharacters: 8, strategy: "head" } }, + }), + ); + + expect(result.records[0]).toEqual( + expect.objectContaining({ + role: "meta", + source: "deepagents-code", + cwd: "/workspace/deep-agent", + }), + ); + expect(result.records.at(-1)).toEqual( + expect.objectContaining({ role: "tool", content: "Sunny, …" }), + ); + expect(result.records.some((record) => + record.role === "assistant" && + record.content === "It is sunny and 22 C in Paris." + )).toBe(false); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "tool_result_truncated", + ); + }); + + integrationTest("forwards a non-root checkpoint namespace", async () => { + const result = await withDeepAgentsCodeHome(() => + normalizeDeepAgentsCode({ + threadId: "thread-123", + checkpointNamespace: "other", + pythonExecutable: PYTHON!, + }), + ); + + expect(result.records.map((record) => record.role)).toEqual([ + "meta", + "user", + "assistant", + ]); + expect(result.records[0]).toEqual({ + role: "meta", + source: "deepagents-code", + }); + expect(result.records[1]).toEqual( + expect.objectContaining({ content: "Other namespace" }), + ); + }); + + test("requires an explicit non-empty threadId before starting Python", async () => { + await expect( + normalizeDeepAgentsCode({ + threadId: "", + pythonExecutable: join(temporaryDirectory, "missing-python"), + }), + ).rejects.toEqual(expect.objectContaining({ code: "invalid_input" })); + }); }); afterAll(() => { @@ -261,3 +442,14 @@ function findLangGraphPython(): string | undefined { } return undefined; } + +async function withDeepAgentsCodeHome(operation: () => Promise): Promise { + const originalHome = process.env.HOME; + process.env.HOME = deepAgentsCodeHome; + try { + return await operation(); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + } +}