langstage-core
The shared core behind the LangStage family: a host layer for LangGraph agents (spec-loading + layered config), an in-process AG-UI bridge that streams any CompiledGraph to a frontend, an async task-delegation engine, and interrupt-aware input helpers. Write your agent once — any LangGraph CompiledGraph — and every LangStage surface runs it the same way.
1.0 — renamed from
langgraph-stream-parser. The oldStreamParser/events/event_to_dictevent layer was retired in favor of the AG-UI wire (see Migrating and ADR 0003). The oldimport langgraph_stream_parserkeeps working as long as the separatelanggraph-stream-parsercompat package stays installed (it re-exportslangstage_core); a fresh install oflangstage-corealone does not provide it.
Every stage for your LangGraph agent
langstage-core is the shared core of the LangStage family: write your agent once — any LangGraph CompiledGraph — and run it on every stage with the same spec string (module:attr or path/to/file.py:attr), the same langstage.toml config file, and the same LANGSTAGE_* environment variables. (The pre-rename deepagents.toml / DEEPAGENT_* vocabulary still resolves as a deprecated fallback.)
| Stage | Package | Try it |
|---|---|---|
| Web app | langstage | langstage run --agent my_agent.py:graph |
| JupyterLab | langstage-jupyter | pip install langstage-jupyter, then the chat sidebar in jupyter lab |
| Terminal | langstage-cli | langstage-cli -a my_agent.py:graph |
| VS Code | langstage-vscode | chat participant + stdio sidecar |
| Reference agent | langstage-hermes | LANGSTAGE_AGENT_SPEC=langstage_hermes.agent:graph on any stage |
| Shared core | langstage-core | you are here |
📖 Full documentation: https://dkedar7.github.io/langstage-docs/
Installation
pip install "langstage-core[agui]"
The [agui] extra pulls the AG-UI runtime (ag-ui-langgraph[fastapi] + uvicorn) — needed for the streaming bridge below and by every LangStage surface. The bare pip install langstage-core (only langchain-core) is enough if you just want the host/config/tasks layer without streaming.
No agent of your own yet? The [stub] extra adds a keyless echo graph you can stream:
pip install "langstage-core[agui,stub]"
Quick start
Wrap any compiled graph with build_agent, then stream a turn. Two shared mappings cover the two frontend styles the family uses:
import asyncio
from langstage_core import load_agent_spec
from langstage_core.agui import build_agent, iter_event_frames
# any LangGraph CompiledGraph — here the keyless demo stub
agent = build_agent(load_agent_spec("langstage_core.demo.stub:graph"))
async def main():
async for frame in iter_event_frames(agent, "hello", thread_id="s1"):
if frame["type"] == "content":
print(frame["content"], end="")
elif frame["type"] == "complete":
print()
asyncio.run(main())
iter_event_framesyields rich, typed frames —content,tool_start,tool_end,reasoning,interrupt,extraction,complete,error— used by the web and VS Code surfaces.iter_chunk_framesyields terminal-friendlystatus-keyed chunk dicts — used by the CLI and Jupyter surfaces. Astreamingchunk carries exactly one payload key (chunk,reasoning,tool_calls,tool_result, orextraction), so branch on the key rather than assumingchunk.
build_agent attaches an in-memory checkpointer if the graph has none (on a copy — your graph object is never mutated), so multi-turn memory and interrupts work out of the box: build the agent once, reuse it, and pass a thread_id per turn to key per-conversation state.
Frame reference
Both wires carry the same information; the table is the contract (keys marked new are additive and safe to ignore).
Event wire (iter_event_frames) |
Chunk wire (iter_chunk_frames) |
Meaning |
|---|---|---|
{"type": "content", "content", "role", "node", "message_id"} |
{"status": "streaming", "chunk", "node", "message_id"} |
Assistant text delta. message_id (new) is the AIMessage it belongs to: a change of id between two text frames is a message boundary (e.g. two nodes' replies) — join with a paragraph break, not inline. |
{"type": "reasoning", "content", "node"} |
{"status": "streaming", "reasoning", "node"} |
Reasoning-model chain-of-thought, separate from the answer. |
{"type": "tool_start", "id", "name", "args", "node"} |
{"status": "streaming", "tool_calls": [{"name", "args", "id"}]} |
A tool call (chunk id is new). |
{"type": "tool_end", "id", "name", "result", "status", "error_message", "duration_ms"} |
{"status": "streaming", "tool_result", "id", "name", "tool_status", "duration_ms"} |
A tool result (capped at max_result_len). status / tool_status is "success" or "error"; duration_ms is the tool's run time, or None when the tool ran outside LangChain's tool runtime (e.g. a hand-written node). Chunk id / name / tool_status / duration_ms are new; tool_result is still the result string. |
{"type": "extraction", "tool_name", "extracted_type", "data"} |
{"status": "streaming", "extraction": {"tool_name", "extracted_type", "data"}} |
An extractor's output for a successful tool result (never emitted for a failed tool). |
{"type": "interrupt", "action_requests", "review_configs", "allowed_decisions"} |
{"status": "interrupt", "interrupt": {...same keys}} |
A HITL pause; resume with resume=. |
{"type": "complete", "outcome"} |
{"status": "complete", "outcome"} |
Terminal. outcome (new) is "interrupted" if the turn paused on an interrupt, else "complete". |
{"type": "error", "error"} |
{"status": "error", "error"} |
Terminal — nothing follows it (no complete). Content earlier nodes already produced is emitted before it. |
Frames arrive in message order: a node that returns a finished AIMessage (no token streaming — model.invoke(), a router, a canned reply) is emitted when that node finishes, its text before its own tool calls, and the served AG-UI endpoint (build_app / serve) streams the same TEXT_MESSAGE_* / TOOL_CALL_* events the in-process wires are built from.
See every frame type, keyless
The echo stub above only emits content. To see the rich frames without an API key, point build_agent at the bundled tool demo (langstage_core.demo.tools:graph): it calls a built-in tool through a real ToolNode, streams a reasoning delta, and raises a resumable interrupt, all deterministically and offline. Each trigger phrase drives a different frame type:
import asyncio
from langstage_core import create_resume_input
from langstage_core.agui import build_agent, iter_event_frames
from langstage_core.demo.tools import create_tool_demo_agent, demo_extractors
agent = build_agent(create_tool_demo_agent())
async def main():
for turn in ("hello", "think about it", "use a tool"):
async for frame in iter_event_frames(agent, turn, "s1", extractors=demo_extractors()):
print(frame["type"], "→", {k: v for k, v in frame.items() if k != "type"})
# "ask me" raises interrupt(...); resume the same thread with a decision.
async for frame in iter_event_frames(agent, "ask me", "s2"):
print(frame["type"]) # ... interrupt, complete (outcome="interrupted")
async for frame in iter_event_frames(agent, "", "s2",
resume=create_resume_input(decisions=[{"type": "approve"}])):
print(frame["type"]) # content, complete
asyncio.run(main())
# content · reasoning · tool_start · tool_end · extraction · interrupt · complete
Serve the same demo over AG-UI with langstage-agui --demo=tools.
One call, one answer (no streaming)
The iter_* mappings are streaming generators — perfect for a live UI, but a test, an eval/grading harness, a batch job, or a "run my agent once, give me the answer" script wants a single call that returns the result. run_turn (sync) / collect_event_frames (async) do exactly that, returning a typed TurnResult (text, tool_calls, extractions, reasoning, outcome, interrupt, error, and frames — an int frame count, not the frame list) — nothing streamed, nothing hand-accumulated:
from langstage_core.agui import run_turn
from langstage_core.demo.tools import create_tool_demo_agent, demo_extractors
result = run_turn(create_tool_demo_agent(), "use a tool", extractors=demo_extractors())
result.text # 'The demo tool returned {"query": "use a tool", "answer": "42", ...}'
result.tool_calls # [{'name': 'demo_lookup', 'args': {'query': 'use a tool'}, 'id': 'demo_lookup_1'}]
result.extractions # [{'tool_name': 'demo_lookup', 'extracted_type': 'demo_fact', 'data': {...}}]
result.outcome # 'complete' ('interrupted' on "ask me", 'error' on a failing turn)
run_turn accepts a compiled graph or a prebuilt build_agent(...) and runs the turn under asyncio.run. Each call is an isolated one-shot by default — a fresh thread_id per call, and the graph you pass is not mutated — so for prompt in dataset: run_turn(graph, prompt) never leaks one turn into the next; to carry state across calls (or resume an interrupt), pass the same build_agent(...) agent and an explicit thread_id each time; inside an event loop, await collect_event_frames(agent, message, thread_id, ...) instead (or collect_chunk_frames for the chunk wire). The complete / interrupted / error verdict is the same rule SessionAdapter uses, so a one-shot turn and a streamed one agree. (The sibling langstage package's oneturn.py is a different layer — it buffers a SessionAdapter for the web one-turn HTTP endpoint; these core helpers are session-free, for tests/evals/scripts.)
Connect a real model
The demos above are keyless. To stream your own model-backed agent, bring any LangGraph CompiledGraph — nothing about the library is demo-specific. The [real] extra pulls a lightweight OpenAI-compatible stack:
pip install "langstage-core[agui,real]" # langchain-openai + langgraph
import asyncio, os
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
from langstage_core.agui import build_agent, iter_event_frames
# Works with OpenAI, OpenRouter, or any OpenAI-compatible endpoint:
model = ChatOpenAI(
model="gpt-4o-mini",
base_url=os.environ.get("OPENAI_BASE_URL"), # e.g. https://openrouter.ai/api/v1
api_key=os.environ["OPENAI_API_KEY"],
)
agent = build_agent(create_react_agent(model, tools=[]))
async def main():
async for frame in iter_event_frames(agent, "Say hi in one word.", thread_id="s1"):
if frame["type"] == "content":
print(frame["content"], end="")
asyncio.run(main())
Everything else — run_turn, serve, the task engine, extractors — takes the same build_agent(...) agent, so the keyless snippets above work verbatim against a real model once you swap the graph. (Prefer Anthropic + the full agent stack? pip install deepagents langchain-anthropic and build a deepagents graph instead; the library only ever sees a CompiledGraph.)
Delegate work to a background task
The task engine is a single-process worker pool: enqueue a prompt, walk away, and read the result off the board when it's done. Any CompiledGraph drives the workers.
import asyncio
from langstage_core import SessionAdapter, load_agent_spec
from langstage_core.tasks import TaskRunner, InMemoryTaskStore, TERMINAL_STATES
async def main():
adapter = SessionAdapter(graph=load_agent_spec("langstage_core.demo.stub:graph"))
runner = TaskRunner(adapter, InMemoryTaskStore(), concurrency=3)
await runner.start()
task_id = await runner.enqueue(title="research", prompt="Summarize the plan.")
# delegate-and-walk-away: poll the board until the task reaches a terminal state
while (task := await runner.store.get(task_id))["state"] not in TERMINAL_STATES:
await asyncio.sleep(0.1)
print(task["state"]) # 'done'
print(task["result"]) # the agent's answer
await runner.shutdown()
asyncio.run(main())
A Task is a TypedDict — read it with task["state"] / task["result"] / task["error"] / task["interrupt"], not attribute access. States flow queued → ongoing → review_needed → done | failed | cancelled; TERMINAL_STATES is the set to stop polling on. TASK_TOOLS (with set_runner / get_runner) are the agent-facing delegation tools, so an agent can enqueue background work to copies of itself.
Human-in-the-loop (interrupt → resume)
When the graph calls interrupt(...), you get an interrupt frame; resume by passing the decision back via resume=:
async for frame in iter_event_frames(agent, "run it", thread_id="s1"):
if frame["type"] == "interrupt":
# frame["action_requests"], frame["allowed_decisions"]
...
# next turn resumes the same thread with the user's decision
async for frame in iter_event_frames(agent, "", thread_id="s1",
resume={"decisions": [{"type": "approve"}]}):
...
Decision types: approve, reject, edit, respond (deepagents 0.6+ / LangGraph 1.1+).
frame["allowed_decisions"] is the interrupt's own decision set, not a fixed list: a standard HumanInterrupt's config (allow_accept → approve, allow_edit → edit, allow_respond → respond, allow_ignore → reject) or a HumanInTheLoopMiddleware payload's per-action review_configs[*].allowed_decisions decide it, so an approve-only interrupt advertises exactly ["approve"]. The full four are the fallback only when the interrupt says nothing.
resume= takes the raw payload or a create_resume_input(...) Command. On ag-ui-langgraph ≥ 0.0.43 it is sent on the adapter's standard RunAgentInput.resume[] (answering the thread's pending interrupt), so a resume logs no forwardedProps.command.resume is deprecated / failed to parse … resume_input as JSON warning; older adapters, or a thread with several pending interrupts, keep the legacy forwarded_props.command.resume wire.
What's in the box
Everything is re-exported from the top-level langstage_core package (except the AG-UI helpers under langstage_core.agui):
| Area | API | What it does |
|---|---|---|
| Host | load_agent_spec, HostConfig, Workspace |
Load a graph from a module:attr / file.py:attr spec; resolve layered config (defaults < langstage.toml < LANGSTAGE_* env < overrides). |
AG-UI bridge (langstage_core.agui) |
build_agent, iter_event_frames, iter_chunk_frames, collect_event_frames / collect_chunk_frames / run_turn (→ TurnResult), build_app, serve, add_agui_endpoint |
Stream any CompiledGraph in-process (the iter_* mappings), collect one turn into a typed TurnResult (the collect_* / run_turn one-shots), or serve it as an AG-UI HTTP endpoint. |
Session adapter (top-level; also langstage_core.adapters) |
SessionAdapter, Session |
A session-scoped driver over the AG-UI agent with a typed terminal outcome — the streaming engine behind the web app + task board. |
| Input helpers | prepare_agent_input, create_resume_input |
Build graph input from a message (+ optional context) or a resume decision. |
| Extractors | ToolExtractor + built-ins (ThinkToolExtractor, TodoExtractor, DisplayInlineExtractor, SkillManageExtractor, MemoryExtractor, …) |
Turn a tool's result into a structured extraction frame; pass extractors=[...] to the iter_* mappings. |
| Task engine | TaskRunner, TaskStore, InMemoryTaskStore, TASK_TOOLS, set_runner, get_runner |
Async delegate-and-walk-away worker pool + a persistence-agnostic store Protocol; TASK_TOOLS are the agent-facing delegation tools. |
Serve any agent over AG-UI
Any LangGraph agent can be served over the AG-UI protocol — the event-based wire for streaming rich agent interactions (text, tool calls, reasoning, state, interrupts) to frontends (CopilotKit, React/Vue/Angular components, any AG-UI client). The host layer resolves which agent; the official MIT ag-ui-langgraph adapter owns the wire:
langstage-agui --agent my_agent.py:graph # serve over AG-UI at http://localhost:8050
langstage-agui --demo # keyless echo agent, no API key
langstage-agui --demo=tools # keyless rich-frame demo (tools, reasoning, interrupt)
langstage-agui --agent my_agent.py:graph --verify # run one keyless turn; exit 0 ok / 1 failed
langstage-agui --agent my_agent.py:graph -m "hi there" # run ONE turn with your prompt, print the reply
--verify is the preflight to run right after wiring up an agent: --show-config proves the config chain resolves a spec, but --verify proves it loads and actually produces a turn — catching the two most common failures (a typo'd module:attr, or a graph that loads but yields an empty/erroring turn) that otherwise only surface at first chat. Keyless, so it fits a CI/deploy gate. --message/-m is its companion — run one turn with your prompt and print the answer (add --json for the typed TurnResult), exit 0/1/2 on complete/error/interrupt. The three questions every adopter asks, in order: --show-config (resolves?) → --verify (runs?) → --message (what does it say?).
Every can't-run failure is a clean one-line error: on stderr, never a traceback (set LANGSTAGE_DEBUG=1 for one): an agent that loads but isn't a runnable graph (e.g. a StateGraph you forgot to .compile()) exits 1 under both --verify and --message (--json still prints a typed TurnResult with outcome: "error"). When serving, the port is bound before the Serving … at <url> banner prints, so a port already in use is error: cannot serve at <url>: … and exit 2 (the serve path's can't-start code, like an unloadable spec), not a success banner followed by a crash. serve() binds first too and raises OSError for a busy port.
from langstage_core.agui import build_app
app = build_app(my_compiled_graph) # an ASGI (FastAPI) app; run with uvicorn
See ADR 0001 for the rationale.
Configuration
The same resolution chain everywhere — defaults < langstage.toml < LANGSTAGE_* env < CLI/overrides (legacy deepagents.toml / DEEPAGENT_* still resolve as a deprecated fallback). Print the resolved value + source of every key:
python -m langstage_core.host # or each surface's --show-config
What the diagnostic tells you:
- Every contributing file.
TOML read from:lists the global~/.langstage/config.tomland the projectlangstage.toml;config_dict()["toml"]["paths"]is the same list as data. - A malformed file is reported as malformed, not missing. A
langstage.tomlthat doesn't parse is ignored entirely (every key falls back to env/defaults) and shows asTOML: <path> is MALFORMED and was ignored entirely (<parse error>). Inconfig_dict()it appears astoml.found: true,toml.malformed: true, andtoml.malformed_files: [{path, error}]. - Anything ignored or degraded, as data.
HostConfig.config_issues()(andconfig_dict()["issues"]) lists each malformed file, each wrong-type or invalid value that fell back to a default, and each unknown key. An empty list means the config is clean, so a surface's--strictgate can fail when the list isn't empty. debugis a top-level key. In TOML,debug = truemust come before the first[table]header. Written below[server], TOML reads it asserver.debug. That key is ignored, and anote:saying so is printed at startup.- Booleans accept
true/false,0/1, and the same quoted strings as env vars ("yes","off", ...). An unrecognized value falls back to the default and prints anote:. [configurable]keys are passed to the graph'sconfig["configurable"]bylangstage-agui(for both serving and--message), and--show-configlists them.thread_idis always set per run. Python callers passbuild_agent(config=...)themselves.- Legacy names (
DEEPAGENT_*,DEEPAGENTS_CONFIG_HOME,deepagents.toml) each print exactly onenote:per process. SetLANGSTAGE_SUPPRESS_LEGACY_NOTICE=1to silence them.
Surfaces print user-controlled values, so they should print through langstage_core.console.safe_print / safe_write. These escape characters the console can't encode (a cp1252 Windows console, for example) instead of raising UnicodeEncodeError.
Agent specs and relative paths
A spec is path/to/file.py:attr or package.module:attr. The :attr suffix is required: a colon-less spec is an error, never a silent fallback to a default agent. Surrounding whitespace is ignored and a leading ~ is expanded. The attribute must be the agent object itself: a str attribute is rejected, not followed as another spec. load_agent_spec imports like python my_agent.py does:
file.py:attrputs the file's own directory first onsys.path, so the agent can import its sibling modules (from tools import ...).package.module:attrfalls back to the current directory (orbase_dir=) when the package isn't otherwise importable. That covers a project-local package run from a console script.load_agent_spec(spec, stdout_to_stderr=True)sends the agent's import-timeprints to stderr. Use it on machine-readable paths.langstage-agui --verify/-m/--jsonalready do.
Relative paths in a TOML file resolve against that file's directory, like paths in pyproject.toml. This applies to [agent] spec (file form) and [workspace] root, for both the project langstage.toml (found by walking up from the cwd) and the global ~/.langstage/config.toml. A project therefore runs the same from its root and from any subdirectory. In the global file, relative paths resolve against ~/.langstage/, so write ~/agents/my_agent.py:graph or an absolute path there. Values from LANGSTAGE_* env vars and CLI flags stay relative to the cwd. For a dotted spec from TOML, cfg.toml_dir_for("agent_spec") gives you the file's directory to pass as base_dir=.
Migrating from langgraph-stream-parser
langstage-core 1.0 is the rename of langgraph-stream-parser. The old import name keeps working through a separate compat package — langgraph-stream-parser 1.0, which now just re-exports langstage_core (with a DeprecationWarning). So import langgraph_stream_parser and its submodules keep resolving only while that package remains installed:
- Upgrading in place (
pip install -U langgraph-stream-parser) → you keep the shim package, so the old import keeps working. Update toimport langstage_corewhen convenient. - Installing
langstage-corefresh does not pull the shim (it's a separate distribution, and depending on it would be circular). Eitherimport langstage_core(recommended), orpip install langgraph-stream-parseralongside if you need the old name during a transition.
The event layer was removed in 1.0. If you used it directly, migrate:
| Removed (pre-1.0) | Use instead |
|---|---|
StreamParser, langstage_core.events, event_to_dict |
langstage_core.agui.iter_event_frames / iter_chunk_frames (frame dicts, same vocabulary) |
stream_graph_updates, resume_graph_from_interrupt |
iter_chunk_frames(agent, msg, thread_id, resume=...) |
adapters.CLIAdapter / PrintAdapter / FastAPIAdapter / JupyterDisplay |
SessionAdapter (in-process) or build_app / serve (HTTP), both AG-UI |
Kept and unchanged: load_agent_spec, HostConfig, prepare_agent_input, create_resume_input, the tasks engine, and the extractors (ToolExtractor + built-ins). Full detail: ADR 0003.
Development
pip install -e ".[dev]"
pytest
pytest --cov=langstage_core
License
MIT
Release files for langstage-core 1.0.36
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| langstage_core-1.0.36.tar.gz | 350.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langstage_core-1.0.36-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 465.0 kB
Release files / langstage_core-1.0.36.tar.gz
| Download URL | langstage_core-1.0.36.tar.gz |
|---|---|
| Size | 350.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5170af0b988540897b403fae64c388535ae531a004811ea7e9c28af0768586d4
|
|
BLAKE2b-256 checksum How to use checksums |
daf94331ffeb1e938c65eed723adaed17328d2984de769a77ca2a94198db5dca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / langstage_core-1.0.36-py3-none-any.whl
| Download URL | langstage_core-1.0.36-py3-none-any.whl |
|---|---|
| Size | 115.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8042ccdd12702d6472f42f63f8e3a9bd10d9a0b25946ecd3e8a644f798f3b779
|
|
BLAKE2b-256 checksum How to use checksums |
44eb4725b761d43ea72d190fec8d42c29f1f4751b17312506a83d819cc1c0715
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|