Skip to content

feat: LangGraph framework integration #1270

Description

@thunguo

Feature description

Add powercontext-langgraph, an adapter distributed from integrations/, connecting a LangGraph graph to a running PowerContext Server through the public Python client.

The adapter provides three components:

  1. powercontext_tools(), returning langchain_core.tools.BaseTool instances for Memory read and write.
  2. PowerContextRecall, a callable usable as a graph node or as a pre_model_hook, which prepares bounded context before a model step.
  3. PowerContextScope, a dataclass intended for the graph context_schema, carrying the durable scope for a run.

Minimal usage:

from langgraph.graph import StateGraph, START
from langgraph.prebuilt import ToolNode
from powercontext_langgraph import PowerContextRecall, PowerContextScope, powercontext_tools

builder = StateGraph(AgentState, context_schema=PowerContextScope)
builder.add_node("recall", PowerContextRecall())
builder.add_node("model", call_model)
builder.add_node("tools", ToolNode([*my_tools, *powercontext_tools()]))
builder.add_edge(START, "recall")
builder.add_edge("recall", "model")

graph = builder.compile(checkpointer=my_checkpointer)

The adapter deliberately does not implement langgraph.store.base.BaseStore, and does not occupy the store parameter of compile(). The rationale is given in the problem statement below and is the primary decision requiring maintainer agreement.

In scope: Memory read and write, bounded context preparation, optional trajectory capture.

Out of scope: checkpointing, Handoff, Artifact Candidate review, and Experience or Skill generation.

Tracked under #1213.

Problem and proposed solution

Problem

PowerContext supports four hosts today: Codex, Claude Code, DeepSeek Harness, and Bub. Each is a command-line coding agent integrated through a host-specific hook or plugin protocol. No integration exists for a general-purpose agent framework, and a LangGraph user must therefore reimplement scope derivation, context injection, capture, flush scheduling, and failure isolation in each project.

LangGraph presents an additional difficulty that does not arise with other frameworks. It defines BaseStore as its cross-thread long-term memory interface, which appears to be the intended seam for an integration of this kind. It is not usable as one, and the reason must be recorded so that the question is settled rather than revisited.

BaseStore declares only batch and abatch as abstract, but those methods must service four operation types. Three of them have no counterpart in the PowerContext Memory model.

Operation Requirement PowerContext status
PutOp Upsert an arbitrary dict under a caller-assigned key POST /v1/memory/remember accepts (scope_id, kind, text, reason?, expected_revision?); entry id and version are assigned by the server. Upsert by caller-assigned key is not available
PutOp with value=None Delete by key. BaseStore.delete is implemented as exactly this Only retire_memory_entry exists. It deactivates rather than deletes, and requires a complete MemoryCitation comprising memory_ref, entry_id, and entry_version_id, which cannot be derived from a caller-assigned key
GetOp Retrieve by caller-assigned key Not available, for the same reason
SearchOp Query by text Maps directly onto search_memory

Implementing the missing three would require embedding a key marker in the memory text and scanning list_memory_entries to resolve entries, which reconstructs Memory's key indexing and version resolution inside the adapter. The second constraint in #1213 prohibits this.

Implementing only SearchOp and raising NotImplementedError for the remainder is also unacceptable. Such an object satisfies every assembly-time check and can be passed to compile(store=...), after which any third-party node or tool invoking store.get() fails at runtime. LangGraph already produces a runtime failure of this shape, in _inject_tool_args: Cannot inject store into tools with InjectedStore annotations - please compile your graph with a store. A partially implemented store is less safe than no store, because it defers the failure to a point where the cause is not local.

The checkpointer interface is separately out of scope. It persists thread-local graph state through Checkpoint, CheckpointTuple, and channel versions, which is not a role PowerContext occupies.

Proposed solution

The adapter integrates at the node and tool level, using LangGraph primitives that are stable public API. All references were verified against langgraph 1.2.11.

Component structure

Component Integration point Responsibility
powercontext_tools() ToolNode, or any tool list Model-initiated Memory read and write
PowerContextRecall add_node, or pre_model_hook Bounded context preparation before a model step
PowerContextCapture add_node Trajectory capture and flush, disabled by default
PowerContextScope context_schema Run-scoped configuration

ToolNode accepts Sequence[BaseTool | Callable], and create_react_agent declares pre_model_hook: RunnableLike | None, so both integration points are ordinary framework parameters rather than private extension mechanisms.

The recall component is a node rather than a graph wrapper because LangGraph users assemble graphs themselves. A node leaves placement and branching to the user, whereas a wrapper API would constrain existing graph structure.

Interface mapping

Tool HTTP operation
powercontext_search POST /v1/memory/search
powercontext_remember POST /v1/memory/remember
powercontext_context POST /v1/context/prepare

Installation and authentication

uv pip install powercontext-langgraph
powercontext server run

Configuration is read through pydantic-settings with the prefix POWERCONTEXT_LANGGRAPH_.

Variable Default Purpose
BASE_URL http://127.0.0.1:8000 Server URL
TOKEN unset Bearer token forwarded to PowerContextClient
SCOPE_ID derived Durable scope shared across runs
TIMEOUT 10 Client timeout in seconds
MAX_BYTES 8000 Prepared context size limit
CAPTURE_EVENTS false Enable trajectory capture
CAPTURE_CHECKPOINT_EVERY 5 Flush interval measured in captured events
CAPTURE_MAX_BYTES 8192 Per-event capture limit

TOKEN carries a bare token rather than a complete Authorization header value, differing from the POWERCONTEXT_*_AUTHORIZATION convention used by the Codex, Claude Code, and DeepSeek Harness plugins. PowerContextClient.__init__ accepts a bare token and composes the header internally. The difference must be stated in the setup documentation.

The adapter installs no files outside the Python environment and modifies no user configuration, so the fourth constraint in #1213 does not apply.

Scope mapping

LangGraph 1.x passes run-scoped configuration through context_schema, which nodes receive as Runtime[ContextT]. The adapter supplies PowerContextScope for that parameter, making the scope an explicit invocation argument:

graph.invoke(state, context=PowerContextScope(scope_id="git:github.com/acme/api"))

This supports multi-tenant deployment directly, since separate invocations of one compiled graph can carry separate scopes.

When no scope is supplied, resolution falls back to derive_scope_id from integrations/codex/plugins/powercontext/scripts/project_scope.py, bounded by MAX_SCOPE_ID_LENGTH as defined in src/powercontext/limits.py. The priority is inverted relative to the Codex plugin: a LangGraph deployment is typically a long-running service in which the working directory has no relationship to the project, so explicit configuration is the primary path and Git derivation is the fallback. When neither an explicit scope nor a Git remote is available, the adapter raises rather than defaulting to local:<sha256>, which would place unrelated tenants in a single scope.

Recall and capture behavior

PowerContextRecall reads the most recent human message from state, calls prepare_context with MAX_BYTES, and prepends the returned content to messages as a system message. When PreparedContext.status is empty, state is returned unmodified.

Injected content is labelled as untrusted historical evidence, following the wording used by the Bub plugin. Memory content originates from prior model output and user input, and presenting it as authoritative system instruction would extend the prompt injection surface to historical data.

PowerContextCapture writes messages as Content Sources and invokes flush_memory every CAPTURE_CHECKPOINT_EVERY events. It is disabled by default because it transmits tool output to the Server. The credential redaction behavior implemented in the Bub plugin must be preserved rather than reimplemented; see open question 3.

One structural limitation should be recorded. Pydantic AI exposes an after_run hook suitable for a terminal flush, and LangGraph has no equivalent end-of-execution callback. The terminal flush is therefore either threshold-driven or requires the user to add a terminating node. This proposal selects threshold-driven behavior as the default and documents the explicit node as an option, rather than requiring users to modify graph structure. See open question 2.

Failure and recovery behavior

Server unavailability must not interrupt graph execution. TransportError, ServerResponseError, and InvalidResponseError, defined in src/powercontext/client/errors.py as subclasses of ClientError, are handled as follows.

Operation Behavior on failure
Recall node Return state unmodified
Capture node Discard the event without retry
Flush Skip. flush_memory advances a cursor, so pending Sources are processed by the next successful flush
Tool call Return the error as the tool result

LangGraph nodes propagate exceptions and terminate the graph by default. The adapter's nodes must therefore handle client errors internally, and must not rely on the user configuring RetryPolicy or an error handler. This preserves the guarantee already established by the Codex, Claude Code, DeepSeek Harness, and Bub integrations, in which Server faults do not block host work.

Tool calls are handled differently from nodes because a model that invoked a search tool and received an empty result will conclude that no relevant memory exists. Returning the error as the tool result allows the model to retry or select a different strategy.

ServerResponseError carries status_code. Responses of 401 and 403 indicate configuration errors rather than transient faults and must produce a distinct log entry on first occurrence.

Tests

The adapter does not modify openapi/powercontext.yaml, so make contract-test is unaffected.

Behavior tests under tests/langgraph_adapter/ drive a graph with a fake chat model against TestClient(create_server_app(...)), following the pattern in tests/e2e/test_runtime_server.py. The assertions cover externally observable behavior:

  • the recall node returns state unmodified when PreparedContext.status is empty;
  • injected content carries the untrusted-evidence label;
  • the graph reaches END while the Server is unreachable;
  • no POST /v1/sources/content request is issued while capture is disabled;
  • a missing scope outside a repository raises rather than defaulting silently.

An end-to-end test at tests/e2e/test_langgraph_chain.py starts a Server and exercises the capture, flush, and search sequence, matching tests/e2e/test_codex_service_chain.py and tests/e2e/test_dsh_http_chain.py.

Per AGENTS.md, the tests assert observable behavior only and do not freeze call counts, call ordering, or internal structure.

Documentation

Setup and troubleshooting documentation is added under docs/en/docs/how-to/ with a Chinese counterpart under docs/zh/, and docs/en/docs/reference/interfaces.md gains a row for the adapter. The documentation must state the token format difference, the inverted scope priority, and the reason BaseStore is not implemented, so that users evaluating the store parameter reach the answer without reading this issue.

Distribution

A separate distribution under integrations/langgraph/, following the precedent set by integrations/bub:

  • distribution name powercontext-langgraph, module powercontext_langgraph;
  • dependencies powercontext[client], langgraph>=1.2,<2, langchain-core, pydantic-settings;
  • build backend hatchling;
  • requires-python = ">=3.11", matching the root package, since langgraph requires only 3.10;
  • the directory added to the [tool.ty] exclude list in the root pyproject.toml.

langchain-core is declared explicitly despite being a transitive dependency of langgraph, because the adapter imports BaseTool from it directly. Adapter code imports nothing from langgraph.prebuilt; ToolNode and create_react_agent appear only in documentation examples, and are installed alongside langgraph in any case, since langgraph-prebuilt>=1.1.0,<1.2.0 is one of its runtime dependencies.

Alternatives considered

Implement BaseStore fully

Assessment: not achievable without reconstructing Memory key indexing and version resolution inside the adapter, since three of the four operation types have no counterpart in the PowerContext Memory model. The second constraint in #1213 prohibits this.

Decision: rejected.

Implement BaseStore partially, supporting search only

Assessment: the resulting object passes assembly-time validation and fails at runtime inside unrelated nodes or tools, at a point where the cause is not local. This is a worse outcome than declining to provide a store.

Decision: rejected. If maintainers prefer this direction regardless, the behavior of get, delete, and list_namespaces must be specified explicitly as part of that decision.

Build the adapter on create_react_agent

Assessment: create_react_agent is marked deprecated in langgraph-prebuilt 1.1.0, with the docstring stating that it is deprecated in favor of create_agent from the langchain package, which provides an equivalent agent factory with a middleware system. AgentState and related types in the same module carry LangGraphDeprecatedSinceV10 and point to langchain.agents. High-level agent assembly is migrating out of the langgraph repository, so an adapter depending on it would inherit that deprecation. This also conflicts with the third constraint in #1213.

Decision: rejected as an implementation dependency. create_react_agent appears in documentation examples only, where pre_model_hook remains a supported parameter. The adapter core depends only on StateGraph, add_node, compile, and Runtime.

Implement the adapter as a langchain.agents middleware

Assessment: potentially the correct long-term form, but the middleware system resides in the langchain repository, which was not examined during this research. No claim is made about its API here.

Decision: deferred to a later phase, contingent on direct evaluation of that API.

Provide a graph wrapper that owns execution

Assessment: this would resolve the missing end-of-execution hook and allow a guaranteed terminal flush, at the cost of constraining the user's graph structure.

Decision: not adopted for the initial release. Recorded as open question 2.

Additional context

Version baseline

Verified against langgraph 1.2.11, with langgraph-checkpoint 4.2.0 and langgraph-prebuilt 1.1.0. The design targets LangGraph 1.x and relies on context_schema and Runtime, which are 1.x APIs.

Constraint compliance

Constraint from #1213 How the design satisfies it
Reuse existing PowerContext interfaces All server interaction is through PowerContextClient and the published HTTP operations
Do not duplicate Runtime or Memory behavior The primary reason BaseStore is not implemented. Entry identity, versioning, and search ranking remain server-side
Do not depend on unsupported upstream extension mechanisms The design uses StateGraph, add_node, compile, Runtime, and context_schema, and excludes the deprecated create_react_agent from the implementation
Report installer effects before modifying the environment Not applicable; the adapter is a library and modifies nothing outside the Python environment

Open questions

  1. The decision not to implement BaseStore requires maintainer agreement, since it determines the documented answer to whether PowerContext can serve as a LangGraph store.
  2. Whether threshold-driven terminal flush is acceptable given the absence of an end-of-execution hook, or whether a graph wrapper is justified.
  3. The credential redaction helpers in the Bub plugin are private to powercontext_bub. A decision on extracting them into a shared location is required before capture is implemented. This question is shared with the Pydantic AI integration.
  4. If scope derivation is extracted for shared use, the priority order must become a parameter, because the LangGraph and Codex integrations require opposite orderings.

Are you willing to contribute to this feature?

  • Yes, I am willing to contribute code, docs, or design feedback.

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions