langstage-core 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langstage_core/__init__.py +85 -0
- langstage_core/adapters/__init__.py +10 -0
- langstage_core/adapters/session.py +309 -0
- langstage_core/agui/__init__.py +424 -0
- langstage_core/agui/__main__.py +128 -0
- langstage_core/demo/__init__.py +22 -0
- langstage_core/demo/agent.py +124 -0
- langstage_core/demo/stub.py +149 -0
- langstage_core/extractors/__init__.py +29 -0
- langstage_core/extractors/base.py +74 -0
- langstage_core/extractors/builtins.py +384 -0
- langstage_core/host/__init__.py +17 -0
- langstage_core/host/__main__.py +32 -0
- langstage_core/host/config.py +403 -0
- langstage_core/host/loader.py +83 -0
- langstage_core/host/workspace.py +42 -0
- langstage_core/resume.py +133 -0
- langstage_core/tasks/__init__.py +57 -0
- langstage_core/tasks/runner.py +342 -0
- langstage_core/tasks/state.py +49 -0
- langstage_core/tasks/store.py +167 -0
- langstage_core/tasks/tools.py +129 -0
- langstage_core-1.0.0.dist-info/METADATA +637 -0
- langstage_core-1.0.0.dist-info/RECORD +27 -0
- langstage_core-1.0.0.dist-info/WHEEL +4 -0
- langstage_core-1.0.0.dist-info/entry_points.txt +2 -0
- langstage_core-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""langstage-core — the host + AG-UI bridge for the LangStage agent family.
|
|
2
|
+
|
|
3
|
+
Since 1.0 (ADR 0003) the bespoke event-translation layer (``StreamParser``, the
|
|
4
|
+
``events`` dataclasses, ``event_to_dict``, the render adapters, and the
|
|
5
|
+
``stream_graph_updates`` helpers) is gone: surfaces stream through the in-process
|
|
6
|
+
AG-UI adapter instead. What remains is the *durable* core —
|
|
7
|
+
|
|
8
|
+
- **host**: ``load_agent_spec`` + ``HostConfig`` + ``Workspace`` (write once,
|
|
9
|
+
run on every surface with the same spec string and ``langstage.toml`` config).
|
|
10
|
+
- **agui**: ``build_agent`` + ``iter_event_frames`` / ``iter_chunk_frames``
|
|
11
|
+
(the in-process AG-UI stream, with an ``extractors=`` hook), ``build_app`` /
|
|
12
|
+
``serve`` for the wire, and ``SessionAdapter`` for session-scoped streaming.
|
|
13
|
+
- **tasks**: the durable task-delegation engine.
|
|
14
|
+
- **extractors**: the ``ToolExtractor`` protocol + reusable built-ins.
|
|
15
|
+
|
|
16
|
+
Quick start::
|
|
17
|
+
|
|
18
|
+
from langstage_core.agui import build_agent, iter_event_frames
|
|
19
|
+
agent = build_agent(my_compiled_graph)
|
|
20
|
+
async for frame in iter_event_frames(agent, "hello", "session-1"):
|
|
21
|
+
... # {"type": "content" | "tool_start" | "interrupt" | ...}
|
|
22
|
+
"""
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
24
|
+
|
|
25
|
+
from .extractors import (
|
|
26
|
+
CompressionExtractor,
|
|
27
|
+
DisplayInlineExtractor,
|
|
28
|
+
GenericToolExtractor,
|
|
29
|
+
MemoryExtractor,
|
|
30
|
+
SkillManageExtractor,
|
|
31
|
+
SkillViewExtractor,
|
|
32
|
+
ThinkToolExtractor,
|
|
33
|
+
TodoExtractor,
|
|
34
|
+
ToolExtractor,
|
|
35
|
+
)
|
|
36
|
+
from .host import HostConfig, Workspace, load_agent_spec
|
|
37
|
+
from .resume import create_resume_input, prepare_agent_input
|
|
38
|
+
from .tasks import (
|
|
39
|
+
InMemoryTaskStore,
|
|
40
|
+
TASK_TOOLS,
|
|
41
|
+
Task,
|
|
42
|
+
TaskRunner,
|
|
43
|
+
TaskState,
|
|
44
|
+
TaskStore,
|
|
45
|
+
current_task_id,
|
|
46
|
+
get_runner,
|
|
47
|
+
outcome_to_state,
|
|
48
|
+
set_runner,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
__version__ = version("langstage-core")
|
|
53
|
+
except PackageNotFoundError: # pragma: no cover - editable/source checkout
|
|
54
|
+
__version__ = "0.0.0+local"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
# Host conventions
|
|
58
|
+
"load_agent_spec",
|
|
59
|
+
"HostConfig",
|
|
60
|
+
"Workspace",
|
|
61
|
+
# Input helpers
|
|
62
|
+
"prepare_agent_input",
|
|
63
|
+
"create_resume_input",
|
|
64
|
+
# Extractors (ToolExtractor protocol + reusable built-ins)
|
|
65
|
+
"ToolExtractor",
|
|
66
|
+
"ThinkToolExtractor",
|
|
67
|
+
"TodoExtractor",
|
|
68
|
+
"DisplayInlineExtractor",
|
|
69
|
+
"GenericToolExtractor",
|
|
70
|
+
"SkillManageExtractor",
|
|
71
|
+
"SkillViewExtractor",
|
|
72
|
+
"MemoryExtractor",
|
|
73
|
+
"CompressionExtractor",
|
|
74
|
+
# Task-delegation engine
|
|
75
|
+
"TaskRunner",
|
|
76
|
+
"TaskStore",
|
|
77
|
+
"InMemoryTaskStore",
|
|
78
|
+
"Task",
|
|
79
|
+
"TaskState",
|
|
80
|
+
"TASK_TOOLS",
|
|
81
|
+
"set_runner",
|
|
82
|
+
"get_runner",
|
|
83
|
+
"current_task_id",
|
|
84
|
+
"outcome_to_state",
|
|
85
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Session adapter for driving a LangGraph agent over the in-process AG-UI stream.
|
|
2
|
+
|
|
3
|
+
The bespoke render adapters (CLI/FastAPI/Jupyter/Print) and the ``StreamParser``
|
|
4
|
+
they wrapped were removed in langstage-core 1.0; ``SessionAdapter`` is now
|
|
5
|
+
AG-UI-only (see ADR 0003).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .session import Session, SessionAdapter
|
|
9
|
+
|
|
10
|
+
__all__ = ["SessionAdapter", "Session"]
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Session-scoped streaming adapter for web hosts.
|
|
2
|
+
|
|
3
|
+
Where :class:`~langstage_core.adapters.fastapi.FastAPIAdapter` is
|
|
4
|
+
request-scoped (one turn per call), ``SessionAdapter`` keeps a long-lived
|
|
5
|
+
session that:
|
|
6
|
+
|
|
7
|
+
- survives client disconnects (a page refresh resumes the same session),
|
|
8
|
+
- multiplexes a per-session event queue so a producer turn and out-of-band
|
|
9
|
+
events (file-change notifications, etc.) interleave on one SSE stream,
|
|
10
|
+
- supports cancelling an in-flight turn,
|
|
11
|
+
- delegates conversation state to LangGraph's checkpointer (keyed by the
|
|
12
|
+
session id used as ``thread_id``).
|
|
13
|
+
|
|
14
|
+
This absorbs the ``session_manager`` + ``sse_adapter`` + chat-route plumbing
|
|
15
|
+
that ``cowork-dash`` used to carry in-tree.
|
|
16
|
+
|
|
17
|
+
Requires: ``pip install langstage-core[fastapi]`` for the SSE helper
|
|
18
|
+
(only the JSON framing needs nothing; ``asyncio`` is stdlib).
|
|
19
|
+
"""
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any, AsyncIterator
|
|
25
|
+
|
|
26
|
+
from ..resume import prepare_agent_input
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Session:
|
|
30
|
+
"""State for a single chat session. Outlives any one transport connection."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, session_id: str | None = None):
|
|
33
|
+
self.id: str = session_id or str(uuid.uuid4())
|
|
34
|
+
self.config: dict[str, Any] = {"configurable": {"thread_id": self.id}}
|
|
35
|
+
self.event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
36
|
+
self.current_task: asyncio.Task | None = None
|
|
37
|
+
self.sse_connected: bool = False
|
|
38
|
+
self.created_at: datetime = datetime.now()
|
|
39
|
+
# Typed terminal outcome of the most recent turn, set by ``_produce``.
|
|
40
|
+
# One of ``"complete" | "interrupted" | "error" | "cancelled"``, or
|
|
41
|
+
# ``None`` before the first turn finishes. Headless consumers (e.g. a
|
|
42
|
+
# task runner) read this instead of re-inspecting the event stream to
|
|
43
|
+
# tell whether a turn finished, paused on a HITL interrupt, or failed.
|
|
44
|
+
self.outcome: str | None = None
|
|
45
|
+
# When ``outcome == "interrupted"``, the serialized InterruptEvent
|
|
46
|
+
# (``{"type": "interrupt", "action_requests": [...], ...}``) that paused
|
|
47
|
+
# the turn — everything a consumer needs to render a review gate and
|
|
48
|
+
# build resume decisions. ``None`` otherwise.
|
|
49
|
+
self.interrupt: dict[str, Any] | None = None
|
|
50
|
+
# When ``outcome in {"error"}``, a human-readable error string.
|
|
51
|
+
self.error: str | None = None
|
|
52
|
+
|
|
53
|
+
def cancel_current(self) -> bool:
|
|
54
|
+
"""Cancel the in-flight turn if any. Returns True if one was cancelled."""
|
|
55
|
+
if self.current_task is not None and not self.current_task.done():
|
|
56
|
+
self.current_task.cancel()
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def push(self, event: dict[str, Any]) -> None:
|
|
61
|
+
"""Enqueue a serialized event for SSE consumers (non-blocking)."""
|
|
62
|
+
# The queue is unbounded, so put_nowait never raises QueueFull and is
|
|
63
|
+
# safe to call from within an except/cancellation block.
|
|
64
|
+
self.event_queue.put_nowait(event)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SessionAdapter:
|
|
68
|
+
"""Session-scoped streaming for LangGraph agents.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
graph: A compiled LangGraph graph (with a checkpointer for resumption).
|
|
72
|
+
stream_mode: Stream mode for ``astream`` and the parser. Defaults to
|
|
73
|
+
dual mode so content streams token-by-token while tool/interrupt
|
|
74
|
+
events arrive complete.
|
|
75
|
+
max_result_len: Max length for serialized tool results (see
|
|
76
|
+
:func:`event_to_dict`).
|
|
77
|
+
parser_kwargs: Extra kwargs forwarded to each ``StreamParser``
|
|
78
|
+
(e.g. ``skip_tools``, custom ``extractors`` via registration).
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
adapter = SessionAdapter(graph=agent)
|
|
82
|
+
|
|
83
|
+
# POST /api/chat → start a turn
|
|
84
|
+
adapter.submit_message(session_id, content, context_parts=[...])
|
|
85
|
+
|
|
86
|
+
# GET /api/stream → consume events (persistent EventSource)
|
|
87
|
+
return StreamingResponse(adapter.sse(session_id),
|
|
88
|
+
media_type="text/event-stream")
|
|
89
|
+
|
|
90
|
+
# POST /api/chat/interrupt → resume from a HITL interrupt
|
|
91
|
+
adapter.submit_decisions(session_id, decisions)
|
|
92
|
+
|
|
93
|
+
# POST /api/chat/cancel
|
|
94
|
+
adapter.cancel(session_id)
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
graph: Any,
|
|
101
|
+
max_result_len: int = 500,
|
|
102
|
+
**_legacy: Any, # accepts + ignores the removed stream_mode/agui/parser kwargs
|
|
103
|
+
):
|
|
104
|
+
self._graph = graph
|
|
105
|
+
self._max_result_len = max_result_len
|
|
106
|
+
self._sessions: dict[str, Session] = {}
|
|
107
|
+
# AG-UI-only since langstage-core 1.0 (ADR 0003): turns stream through the
|
|
108
|
+
# in-process AG-UI adapter (``agui.iter_event_frames``) — the wrapped agent
|
|
109
|
+
# is built lazily and reused (its checkpointer keys per-session by thread_id).
|
|
110
|
+
self._agui_agent: Any = None
|
|
111
|
+
|
|
112
|
+
# ── Session lifecycle ────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def get_or_create(self, session_id: str | None = None) -> Session:
|
|
115
|
+
"""Resume an existing session by id, or create a fresh one.
|
|
116
|
+
|
|
117
|
+
A provided-but-unknown ``session_id`` is honored as the new session's
|
|
118
|
+
id, so a reconnecting client keeps its ``thread_id`` (and thus its
|
|
119
|
+
checkpointed history).
|
|
120
|
+
"""
|
|
121
|
+
if session_id and session_id in self._sessions:
|
|
122
|
+
return self._sessions[session_id]
|
|
123
|
+
session = Session(session_id)
|
|
124
|
+
self._sessions[session.id] = session
|
|
125
|
+
return session
|
|
126
|
+
|
|
127
|
+
def get(self, session_id: str) -> Session | None:
|
|
128
|
+
"""Look up a session by id, or None."""
|
|
129
|
+
return self._sessions.get(session_id)
|
|
130
|
+
|
|
131
|
+
def delete_session(self, session_id: str) -> bool:
|
|
132
|
+
"""Cancel and remove a session. Returns True if it existed."""
|
|
133
|
+
session = self._sessions.pop(session_id, None)
|
|
134
|
+
if session is None:
|
|
135
|
+
return False
|
|
136
|
+
session.cancel_current()
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
140
|
+
"""Summaries of all live sessions."""
|
|
141
|
+
return [
|
|
142
|
+
{
|
|
143
|
+
"session_id": s.id,
|
|
144
|
+
"created_at": s.created_at.isoformat(),
|
|
145
|
+
"connected": s.sse_connected,
|
|
146
|
+
}
|
|
147
|
+
for s in self._sessions.values()
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def active_count(self) -> int:
|
|
152
|
+
"""Number of sessions with a connected SSE consumer."""
|
|
153
|
+
return sum(1 for s in self._sessions.values() if s.sse_connected)
|
|
154
|
+
|
|
155
|
+
# ── Producing turns ──────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def submit_message(
|
|
158
|
+
self,
|
|
159
|
+
session_id: str | None,
|
|
160
|
+
content: str,
|
|
161
|
+
*,
|
|
162
|
+
context_parts: list[str] | None = None,
|
|
163
|
+
) -> Session:
|
|
164
|
+
"""Start a turn for a user message. Cancels any in-flight turn first.
|
|
165
|
+
|
|
166
|
+
The turn runs as a background task that pushes serialized events onto
|
|
167
|
+
the session's queue; consume them via :meth:`sse`.
|
|
168
|
+
"""
|
|
169
|
+
session = self.get_or_create(session_id)
|
|
170
|
+
session.cancel_current()
|
|
171
|
+
# Reuse prepare_agent_input's context-combining, then hand the raw text to
|
|
172
|
+
# the AG-UI producer (which builds its own RunAgentInput).
|
|
173
|
+
combined = prepare_agent_input(message=content, context_parts=context_parts)
|
|
174
|
+
text = combined["messages"][0]["content"]
|
|
175
|
+
session.current_task = asyncio.create_task(self._produce(session, message=text))
|
|
176
|
+
return session
|
|
177
|
+
|
|
178
|
+
def submit_decisions(
|
|
179
|
+
self,
|
|
180
|
+
session_id: str | None,
|
|
181
|
+
decisions: list[dict[str, Any]],
|
|
182
|
+
) -> Session:
|
|
183
|
+
"""Resume a session from an interrupt with HITL decisions."""
|
|
184
|
+
session = self.get_or_create(session_id)
|
|
185
|
+
session.cancel_current()
|
|
186
|
+
session.current_task = asyncio.create_task(
|
|
187
|
+
self._produce(session, resume={"decisions": decisions})
|
|
188
|
+
)
|
|
189
|
+
return session
|
|
190
|
+
|
|
191
|
+
def cancel(self, session_id: str) -> bool:
|
|
192
|
+
"""Cancel the in-flight turn for a session. Returns True if one ran."""
|
|
193
|
+
session = self._sessions.get(session_id)
|
|
194
|
+
if session is None:
|
|
195
|
+
return False
|
|
196
|
+
return session.cancel_current()
|
|
197
|
+
|
|
198
|
+
def push_event(self, session_id: str | None, event: dict[str, Any]) -> Session:
|
|
199
|
+
"""Push an out-of-band event onto a session's stream (side channel).
|
|
200
|
+
|
|
201
|
+
Use for events that don't originate from the agent — e.g. a file
|
|
202
|
+
watcher pushing ``{"type": "file_changed", ...}`` into the same SSE
|
|
203
|
+
stream the agent events flow through.
|
|
204
|
+
"""
|
|
205
|
+
session = self.get_or_create(session_id)
|
|
206
|
+
session.push(event)
|
|
207
|
+
return session
|
|
208
|
+
|
|
209
|
+
async def _produce(
|
|
210
|
+
self,
|
|
211
|
+
session: Session,
|
|
212
|
+
*,
|
|
213
|
+
message: str = "",
|
|
214
|
+
resume: Any = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Run one turn through the in-process AG-UI adapter, pushing serialized
|
|
217
|
+
``event_to_dict`` frames onto the session queue and recording the terminal
|
|
218
|
+
outcome (``session.outcome`` + ``session.interrupt`` / ``session.error``) so
|
|
219
|
+
headless consumers (the task runner) don't re-inspect the event stream.
|
|
220
|
+
"""
|
|
221
|
+
from ..agui import build_agent, iter_event_frames
|
|
222
|
+
|
|
223
|
+
session.outcome = None
|
|
224
|
+
session.interrupt = None
|
|
225
|
+
session.error = None
|
|
226
|
+
pending_interrupt: dict[str, Any] | None = None
|
|
227
|
+
if self._agui_agent is None:
|
|
228
|
+
self._agui_agent = build_agent(self._graph)
|
|
229
|
+
# Clone per turn: the ag-ui-langgraph agent carries per-run state, so
|
|
230
|
+
# concurrent sessions (the task runner runs many at once) must not share
|
|
231
|
+
# one instance. clone() keeps the graph + checkpointer (thread state) but
|
|
232
|
+
# isolates the run — the same pattern build_app uses per request.
|
|
233
|
+
run_agent = self._agui_agent.clone()
|
|
234
|
+
thread_id = session.config.get("configurable", {}).get("thread_id", session.id)
|
|
235
|
+
try:
|
|
236
|
+
async for data in iter_event_frames(
|
|
237
|
+
run_agent,
|
|
238
|
+
message,
|
|
239
|
+
thread_id,
|
|
240
|
+
resume=resume,
|
|
241
|
+
max_result_len=self._max_result_len,
|
|
242
|
+
):
|
|
243
|
+
session.push(data)
|
|
244
|
+
kind = data.get("type")
|
|
245
|
+
if kind == "interrupt":
|
|
246
|
+
pending_interrupt = data
|
|
247
|
+
elif kind == "error":
|
|
248
|
+
session.outcome = "error"
|
|
249
|
+
session.error = data.get("error")
|
|
250
|
+
return
|
|
251
|
+
elif kind == "complete":
|
|
252
|
+
if pending_interrupt is not None:
|
|
253
|
+
session.outcome = "interrupted"
|
|
254
|
+
session.interrupt = pending_interrupt
|
|
255
|
+
else:
|
|
256
|
+
session.outcome = "complete"
|
|
257
|
+
return
|
|
258
|
+
except asyncio.CancelledError:
|
|
259
|
+
session.outcome = "cancelled"
|
|
260
|
+
session.push({"type": "cancelled"})
|
|
261
|
+
raise
|
|
262
|
+
except Exception as exc: # noqa: BLE001 — surfaced to the client, not swallowed
|
|
263
|
+
session.outcome = "error"
|
|
264
|
+
session.error = f"{type(exc).__name__}: {exc}"
|
|
265
|
+
session.push({"type": "error", "error": f"{type(exc).__name__}: {exc}"})
|
|
266
|
+
|
|
267
|
+
# ── Consuming (SSE) ──────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
async def sse(
|
|
270
|
+
self,
|
|
271
|
+
session_id: str | None = None,
|
|
272
|
+
*,
|
|
273
|
+
keepalive: float = 30.0,
|
|
274
|
+
send_init: bool = True,
|
|
275
|
+
) -> AsyncIterator[str]:
|
|
276
|
+
"""Yield Server-Sent Events for a session, persistently.
|
|
277
|
+
|
|
278
|
+
Drains the session queue, emitting one ``data: {json}\\n\\n`` frame per
|
|
279
|
+
event. On idle, emits a ``: keepalive`` comment every ``keepalive``
|
|
280
|
+
seconds to keep proxies from closing the connection. The generator runs
|
|
281
|
+
until the consumer stops iterating (e.g. the ASGI server detects the
|
|
282
|
+
client disconnected), so a single EventSource spans many turns.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
session_id: Resume this session, or create one if None/unknown.
|
|
286
|
+
keepalive: Seconds between keepalive comments while idle.
|
|
287
|
+
send_init: Emit a ``session_init`` frame first so the client learns
|
|
288
|
+
its (possibly newly minted) session id for reconnects.
|
|
289
|
+
"""
|
|
290
|
+
session = self.get_or_create(session_id)
|
|
291
|
+
session.sse_connected = True
|
|
292
|
+
try:
|
|
293
|
+
if send_init:
|
|
294
|
+
yield _sse_frame({"type": "session_init", "session_id": session.id})
|
|
295
|
+
while True:
|
|
296
|
+
try:
|
|
297
|
+
event = await asyncio.wait_for(
|
|
298
|
+
session.event_queue.get(), timeout=keepalive
|
|
299
|
+
)
|
|
300
|
+
yield _sse_frame(event)
|
|
301
|
+
except asyncio.TimeoutError:
|
|
302
|
+
yield ": keepalive\n\n"
|
|
303
|
+
finally:
|
|
304
|
+
session.sse_connected = False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _sse_frame(event: dict[str, Any]) -> str:
|
|
308
|
+
"""Format an event dict as a single SSE ``data:`` frame."""
|
|
309
|
+
return f"data: {json.dumps(event)}\n\n"
|